Skip to main content

motion_core/
instances.rs

1//! Validated opaque instance records shared by content and render packages.
2
3use crate::drawing::BlendSpace;
4use crate::{Point, Rect, ValidationError};
5use std::{any::Any, fmt::Debug};
6
7mod analytic;
8
9pub use analytic::{
10    ANALYTIC_HEADER_WORDS, ANALYTIC_STATE_WORDS, AnalyticInstanceState, AnalyticInstanceUpload,
11    AnalyticTrajectoryKind,
12};
13
14/// Composites one already-covered linear-premultiplied instance sample.
15pub type InstanceComposite = fn(&mut [f32; 4], [f32; 4], BlendSpace) -> Result<(), ValidationError>;
16
17/// Optional ordered CPU sampling with conservative per-instance support.
18/// Outside a declared support rectangle an instance must produce an all-zero source.
19pub trait SpatialInstances: Debug + Send + Sync {
20    /// Local support, including antialiasing and numeric guards, for one instance.
21    fn support(&self, index: usize, antialias: f64) -> Rect;
22
23    /// Samples only the ascending, in-range instance indices supplied by a consumer.
24    /// The compositor must treat all-zero sources as infallible identities.
25    fn sample_indices(
26        &self,
27        indices: &[usize],
28        point: Point,
29        antialias: f64,
30        destination: [f32; 4],
31        space: BlendSpace,
32        composite: InstanceComposite,
33    ) -> Result<[f32; 4], ValidationError>;
34}
35
36/// Maximum portable batch size; allocations and sampling remain budget-limited.
37pub const MAX_INSTANCE_COUNT: usize = 1_048_576;
38
39/// Fewest sides accepted for polygon and bladed bokeh instances.
40const MIN_POLYGON_SIDES: f32 = 3.0;
41
42/// Most sides accepted for polygon and bladed bokeh instances.
43const MAX_POLYGON_SIDES: f32 = 32.0;
44
45/// Largest stretch or aspect ratio accepted by elongated, quad and sprite-sheet instances.
46const MAX_ASPECT: f32 = 64.0;
47
48/// Coverage and compositing of one evaluated primitive.
49#[derive(Clone, Copy, Debug, PartialEq, Eq)]
50#[repr(u32)]
51pub enum InstanceKind {
52    /// Antialiased filled circle using SourceOver.
53    Disc,
54    /// Gaussian circle using linear additive compositing.
55    Glow,
56    /// Rotated square sampling the batch's shared RGBA image.
57    Sprite,
58    /// Antialiased segment from start to position using SourceOver.
59    Segment,
60    /// Sprite with a normalized UV rectangle in the extension words.
61    SpriteSheet,
62    /// Segment with endpoint colors and radii.
63    TaperedSegment,
64    /// Regular polygon; extension contains side count and aspect.
65    Polygon,
66    /// Annular disc; extension contains relative thickness.
67    Ring,
68    /// Velocity-oriented capsule.
69    Capsule,
70    /// Disc with adjustable edge hardness.
71    Soft,
72    /// Rotated rectangular particle.
73    Quad,
74    /// Energy-compensated aperture with an optional bright rim.
75    Bokeh,
76    /// Flat-ended tapered strip between two historical positions.
77    Ribbon,
78}
79
80/// Batch-level compositing applied to every primitive in a batch.
81#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
82#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
83#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
84#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
85#[repr(u32)]
86pub enum InstanceBlend {
87    /// Premultiplied source-over compositing.
88    #[default]
89    SourceOver,
90    /// Additive linear RGB with bounded alpha.
91    Plus,
92    /// Screen compositing.
93    Screen,
94    /// Multiply compositing.
95    Multiply,
96}
97
98/// Backend-independent evaluated geometry, encoded as twelve device words.
99#[derive(Clone, Copy, Debug, PartialEq)]
100pub struct InstanceRecord {
101    /// Circle center or segment endpoint in local coordinates.
102    pub position: [f32; 2],
103    /// Segment start; ignored by other primitive kinds.
104    pub start: [f32; 2],
105    /// Linear-premultiplied RGBA multiplier; finite HDR RGB is supported.
106    pub color: [f32; 4],
107    /// Circle radius or sprite half extent; zero disables the primitive.
108    pub radius: f32,
109    /// Evaluated sprite angle in radians, including velocity alignment.
110    pub rotation: f32,
111    /// Full segment width in local coordinates.
112    pub width: f32,
113    /// Evaluated coverage and compositing rule.
114    pub kind: InstanceKind,
115}
116
117impl InstanceRecord {
118    fn words(self) -> Result<[f32; 12], ValidationError> {
119        let words = [
120            self.position[0],
121            self.position[1],
122            self.start[0],
123            self.start[1],
124            self.color[0],
125            self.color[1],
126            self.color[2],
127            self.color[3],
128            self.radius,
129            self.rotation,
130            self.width,
131            self.kind as u32 as f32,
132        ];
133        if words.iter().any(|value| !value.is_finite())
134            || self.radius < 0.0
135            || self.width < 0.0
136            || self.color[3] < 0.0
137        {
138            return Err(ValidationError::invalid(
139                "instances.upload",
140                "record",
141                "Primitive geometry and color must be finite with nonnegative size and alpha",
142            ));
143        }
144        Ok(words)
145    }
146}
147
148/// Versioned sixteen-word primitive, leaving the original record layout intact.
149/// SpriteSheet uses UV bounds; tapered segments use the other endpoint color;
150/// analytic shapes use kind-specific finite parameters.
151#[derive(Clone, Copy, Debug, PartialEq)]
152pub struct ExtendedInstanceRecord {
153    /// Common twelve-word geometry. Tapered width is the start diameter and
154    /// radius is the endpoint radius.
155    pub base: InstanceRecord,
156    /// UV bounds, endpoint RGBA, or analytic shape parameters.
157    pub extra: [f32; 4],
158}
159
160impl ExtendedInstanceRecord {
161    /// Validates extended geometry without allocating or encoding a batch.
162    pub fn validate(self) -> Result<(), ValidationError> {
163        self.words().map(|_| ())
164    }
165
166    fn words(self) -> Result<[f32; 16], ValidationError> {
167        let mut words = [0.0; 16];
168        words[..12].copy_from_slice(&self.base.words()?);
169        let parameters = match self.base.kind {
170            InstanceKind::Polygon => {
171                (MIN_POLYGON_SIDES..=MAX_POLYGON_SIDES).contains(&self.extra[0])
172                    && self.extra[0].fract() == 0.0
173            }
174            InstanceKind::Ring => self.extra[0] > 0.0 && self.extra[0] <= 1.0,
175            InstanceKind::Soft => (0.0..=1.0).contains(&self.extra[0]),
176            InstanceKind::Capsule | InstanceKind::Sprite => {
177                (1.0..=MAX_ASPECT).contains(&self.extra[2])
178            }
179            InstanceKind::Quad => self.extra[2] > 0.0 && self.extra[2] <= MAX_ASPECT,
180            InstanceKind::Bokeh => {
181                (self.extra[0] == 0.0
182                    || ((MIN_POLYGON_SIDES..=MAX_POLYGON_SIDES).contains(&self.extra[0])
183                        && self.extra[0].fract() == 0.0))
184                    && (0.0..=1.0).contains(&self.extra[1])
185                    && (0.0..=1.0).contains(&self.extra[2])
186            }
187            InstanceKind::SpriteSheet => self.base.width > 0.0 && self.base.width <= MAX_ASPECT,
188            _ => true,
189        };
190        if self.extra.iter().any(|value| !value.is_finite())
191            || !parameters
192            || self.base.color[3] > 1.0
193            || (self.base.kind == InstanceKind::SpriteSheet
194                && (self.extra.iter().any(|value| !(0.0..=1.0).contains(value))
195                    || self.extra[0] >= self.extra[2]
196                    || self.extra[1] >= self.extra[3]))
197            || (matches!(
198                self.base.kind,
199                InstanceKind::TaperedSegment | InstanceKind::Ribbon
200            ) && !(0.0..=1.0).contains(&self.extra[3]))
201        {
202            return Err(ValidationError::invalid(
203                "instances.upload",
204                "extension",
205                "Invalid finite extension, UV bounds or endpoint alpha",
206            ));
207        }
208        words[12..].copy_from_slice(&self.extra);
209        Ok(words)
210    }
211}
212
213/// Checked immutable device payload without graphics API handles.
214#[derive(Debug)]
215pub struct InstanceUpload {
216    words: Vec<f32>,
217}
218
219impl InstanceUpload {
220    /// Encodes checked twelve-word source-over primitives followed by a shared
221    /// row-major linear RGBA image. A zero image size requires no texels; sprite
222    /// records require a nonempty image.
223    pub fn new(
224        records: impl ExactSizeIterator<Item = InstanceRecord>,
225        sprite_size: [u32; 2],
226        texels: impl IntoIterator<Item = [f32; 4]>,
227    ) -> Result<Self, ValidationError> {
228        Self::new_blended(records, sprite_size, texels, InstanceBlend::SourceOver)
229    }
230
231    /// Like [`Self::new`], with an explicit batch compositing rule.
232    pub fn new_blended(
233        records: impl ExactSizeIterator<Item = InstanceRecord>,
234        sprite_size: [u32; 2],
235        texels: impl IntoIterator<Item = [f32; 4]>,
236        blend: InstanceBlend,
237    ) -> Result<Self, ValidationError> {
238        Self::encode(
239            records.map(|record| Ok((record.kind, record.words()?))),
240            sprite_size,
241            texels,
242            blend,
243        )
244    }
245
246    /// Encodes versioned sixteen-word primitives and a batch compositing rule.
247    pub fn new_extended(
248        records: impl ExactSizeIterator<Item = ExtendedInstanceRecord>,
249        sprite_size: [u32; 2],
250        texels: impl IntoIterator<Item = [f32; 4]>,
251        blend: InstanceBlend,
252    ) -> Result<Self, ValidationError> {
253        Self::encode(
254            records.map(|record| Ok((record.base.kind, record.words()?))),
255            sprite_size,
256            texels,
257            blend,
258        )
259    }
260
261    fn encode<const WORDS: usize>(
262        records: impl ExactSizeIterator<Item = Result<(InstanceKind, [f32; WORDS]), ValidationError>>,
263        sprite_size: [u32; 2],
264        texels: impl IntoIterator<Item = [f32; 4]>,
265        blend: InstanceBlend,
266    ) -> Result<Self, ValidationError> {
267        let invalid = || {
268            crate::ValidationError::invalid(
269                "instances.upload",
270                "layout",
271                "Primitive upload must match its bounded count, geometry and image dimensions",
272            )
273        };
274        let instances = records.len();
275        let [width, height] = sprite_size;
276        if instances > MAX_INSTANCE_COUNT
277            || width > 16_384
278            || height > 16_384
279            || (width == 0) != (height == 0)
280        {
281            return Err(invalid());
282        }
283        let pixels = (width as usize)
284            .checked_mul(height as usize)
285            .ok_or_else(invalid)?;
286        let expected = instances
287            .checked_mul(WORDS)
288            .and_then(|records| {
289                pixels
290                    .checked_mul(4)
291                    .and_then(|pixels| records.checked_add(pixels))
292            })
293            .and_then(|payload| payload.checked_add(4))
294            .ok_or_else(invalid)?;
295        let mut words = Vec::new();
296        words.try_reserve_exact(expected).map_err(|_| invalid())?;
297        // Header protocol: record stride in the low five bits, blend rule above them.
298        let layout = WORDS as u32 + ((blend as u32) << 5);
299        words.extend([instances as f32, width as f32, height as f32, layout as f32]);
300        for (index, record) in records.enumerate() {
301            let (kind, record) = record?;
302            if index >= instances
303                || (matches!(kind, InstanceKind::Sprite | InstanceKind::SpriteSheet) && pixels == 0)
304                || (WORDS == 12 && kind as u32 > InstanceKind::Segment as u32)
305            {
306                return Err(invalid());
307            }
308            words.extend(record);
309        }
310        if words.len() != 4 + instances * WORDS {
311            return Err(invalid());
312        }
313        for (index, texel) in texels.into_iter().enumerate() {
314            if index >= pixels || texel.iter().any(|value| !value.is_finite()) || texel[3] < 0.0 {
315                return Err(invalid());
316            }
317            words.extend(texel);
318        }
319        if words.len() != expected {
320            return Err(invalid());
321        }
322        Ok(Self { words })
323    }
324
325    /// Number of encoded primitives.
326    pub fn len(&self) -> usize {
327        self.words[0] as usize
328    }
329
330    /// Whether no primitives are encoded.
331    pub fn is_empty(&self) -> bool {
332        self.len() == 0
333    }
334
335    /// Exact logical byte charge, including the header and shared image.
336    pub fn bytes(&self) -> u64 {
337        self.words.len() as u64 * 4
338    }
339
340    /// Transfers the validated words to the device planner without another allocation.
341    pub fn into_words(self) -> Vec<f32> {
342        self.words
343    }
344}
345
346/// Immutable instance data, independent of a particular renderer or simulator.
347pub trait InstanceBatch: Any + Debug + Send + Sync {
348    /// Number of independently painted instances.
349    fn len(&self) -> usize;
350
351    /// Whether no instances are present.
352    fn is_empty(&self) -> bool {
353        self.len() == 0
354    }
355
356    /// Batch compositing policy; the default is source-over.
357    fn blend(&self) -> InstanceBlend {
358        InstanceBlend::SourceOver
359    }
360
361    /// Conservative local bounds.
362    fn bounds(&self) -> Rect;
363
364    /// Retained logical bytes charged during preparation.
365    fn bytes(&self) -> u64;
366
367    /// Conservative sampling work per output pixel.
368    fn work(&self) -> u64;
369
370    /// Optional conservative supports and ordered sampling; defaults to full-batch traversal.
371    fn spatial(&self) -> Option<&dyn SpatialInstances> {
372        None
373    }
374
375    /// Validates all stored instance/style values, including hidden instances.
376    fn validate(&self) -> Result<(), ValidationError>;
377
378    /// Samples instances in their declared order using the consumer's compositor.
379    fn sample(
380        &self,
381        point: Point,
382        antialias: f64,
383        destination: [f32; 4],
384        space: BlendSpace,
385        composite: InstanceComposite,
386    ) -> Result<[f32; 4], ValidationError>;
387
388    /// Samples with a compositor for which every all-zero source, including
389    /// signed zeros, is an infallible identity without observable side effects.
390    /// Implementations may omit those calls, but must preserve nonzero sample
391    /// order and errors. The default retains the ordinary sampling behavior.
392    /// Callers with an arbitrary compositor must use [`Self::sample`] instead.
393    fn sample_with_zero_identity(
394        &self,
395        point: Point,
396        antialias: f64,
397        destination: [f32; 4],
398        space: BlendSpace,
399        composite: InstanceComposite,
400    ) -> Result<[f32; 4], ValidationError> {
401        self.sample(point, antialias, destination, space, composite)
402    }
403
404    /// Exact upload charge for preflight admission; zero by default for CPU-only batches.
405    fn upload_bytes(&self) -> u64 {
406        0
407    }
408
409    /// Checked evaluated primitives, or None when only CPU sampling is available.
410    fn upload(&self) -> Result<Option<InstanceUpload>, ValidationError> {
411        Ok(None)
412    }
413
414    /// Optional checked seeded trajectory program replacing evaluated primitive positions.
415    /// Existing generic batches and fixed-step simulations retain their ordinary uploads.
416    fn analytic_upload(&self) -> Option<&AnalyticInstanceUpload> {
417        None
418    }
419
420    /// Explicit reason an otherwise analytic batch retains evaluated CPU primitives.
421    fn analytic_fallback(&self) -> Option<&ValidationError> {
422        None
423    }
424
425    /// Resolves concrete content only within its owning implementation.
426    fn as_any(&self) -> &dyn Any;
427
428    /// Compares the logical payload of two instance records.
429    fn content_eq(&self, other: &dyn InstanceBatch) -> bool;
430}
431
432impl PartialEq for dyn InstanceBatch {
433    fn eq(&self, other: &Self) -> bool {
434        self.content_eq(other)
435    }
436}
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441
442    #[test]
443    fn versioned_uploads_share_the_header_and_validate_analytic_extensions() {
444        let base = InstanceRecord {
445            position: [1.0, 2.0],
446            start: [0.0; 2],
447            color: [2.0, 0.1, 0.0, 0.5],
448            radius: 2.0,
449            rotation: 0.0,
450            width: 1.0,
451            kind: InstanceKind::Disc,
452        };
453        let old = InstanceUpload::new(std::iter::once(base), [0, 0], [])
454            .unwrap()
455            .into_words();
456        let extended = ExtendedInstanceRecord {
457            base,
458            extra: [0.0, 0.0, 1.0, 0.0],
459        };
460        let new = InstanceUpload::new_extended(
461            std::iter::once(extended),
462            [0, 0],
463            [],
464            InstanceBlend::Screen,
465        )
466        .unwrap()
467        .into_words();
468        assert_eq!(old[3], 12.0);
469        assert_eq!(new[3], 16.0 + ((InstanceBlend::Screen as u32) << 5) as f32);
470        assert_eq!(&old[4..], &new[4..16]);
471        assert_eq!(new.len(), 20);
472        for (kind, parameters) in [
473            (InstanceKind::Polygon, [2.0, 0.0, 1.0, 0.0]),
474            (InstanceKind::Ring, [0.0; 4]),
475            (InstanceKind::Quad, [0.0; 4]),
476            (InstanceKind::Soft, [2.0, 0.0, 1.0, 0.0]),
477            (InstanceKind::Bokeh, [4.5, 0.0, 1.0, 0.0]),
478            (InstanceKind::TaperedSegment, [0.0, 0.0, 0.0, 1.1]),
479            (InstanceKind::SpriteSheet, [0.5, 0.0, 0.4, 1.0]),
480        ] {
481            let record = ExtendedInstanceRecord {
482                base: InstanceRecord { kind, ..base },
483                extra: parameters,
484            };
485            assert!(record.validate().is_err(), "{kind:?}");
486            assert!(InstanceUpload::new(std::iter::once(record.base), [0, 0], []).is_err());
487        }
488    }
489
490    #[test]
491    fn primitive_records_are_compact_and_validate_hidden_geometry() {
492        let record = InstanceRecord {
493            position: [3.0, 4.0],
494            start: [-1.0, 2.0],
495            color: [-0.5, 2.0, 0.0, 0.5],
496            radius: 2.0,
497            rotation: 0.75,
498            width: 1.0,
499            kind: InstanceKind::Segment,
500        };
501        let words = record.words().unwrap();
502        assert_eq!(std::mem::size_of_val(&words), 48);
503        assert_eq!(&words[..4], &[3.0, 4.0, -1.0, 2.0]);
504        assert_eq!(words[11], 3.0);
505        for invalid in [
506            InstanceRecord {
507                position: [f32::NAN, 0.0],
508                ..record
509            },
510            InstanceRecord {
511                start: [f32::INFINITY, 0.0],
512                ..record
513            },
514            InstanceRecord {
515                color: [0.0, 0.0, 0.0, -1.0],
516                ..record
517            },
518            InstanceRecord {
519                radius: -1.0,
520                ..record
521            },
522            InstanceRecord {
523                rotation: f32::NAN,
524                ..record
525            },
526            InstanceRecord {
527                width: -1.0,
528                ..record
529            },
530        ] {
531            assert!(invalid.words().is_err());
532        }
533    }
534
535    #[test]
536    fn primitive_upload_checks_sprite_texels_and_exact_byte_charge() {
537        let record = InstanceRecord {
538            position: [1.0, 2.0],
539            start: [0.0; 2],
540            color: [0.5; 4],
541            radius: 2.0,
542            rotation: 0.0,
543            width: 1.0,
544            kind: InstanceKind::Sprite,
545        };
546        let upload = InstanceUpload::new([record].into_iter(), [2, 1], [[0.5; 4]; 2]).unwrap();
547        assert_eq!(upload.len(), 1);
548        assert_eq!(upload.bytes(), (4 + 12 + 8) * 4);
549        assert_eq!(&upload.into_words()[..4], &[1.0, 2.0, 1.0, 12.0]);
550        assert!(InstanceUpload::new([record].into_iter(), [0, 0], []).is_err());
551        assert!(InstanceUpload::new([record].into_iter(), [2, 0], []).is_err());
552        assert!(InstanceUpload::new([record].into_iter(), [16_385, 1], []).is_err());
553        assert!(InstanceUpload::new([record].into_iter(), [2, 1], [[0.0; 4]]).is_err());
554        assert!(InstanceUpload::new([record].into_iter(), [1, 1], [[0.0; 4]; 2]).is_err());
555        assert!(InstanceUpload::new([record].into_iter(), [1, 1], [[f32::NAN; 4]]).is_err());
556        let record = InstanceRecord {
557            kind: InstanceKind::Disc,
558            ..record
559        };
560        let upload = InstanceUpload::new(std::iter::repeat_n(record, 65_537), [0, 0], []).unwrap();
561        assert_eq!(upload.len(), 65_537);
562        assert_eq!(upload.bytes(), 16 + 65_537 * 48);
563        assert!(
564            InstanceUpload::new(
565                std::iter::repeat_n(record, MAX_INSTANCE_COUNT + 1),
566                [0, 0],
567                []
568            )
569            .is_err()
570        );
571        let empty = InstanceUpload::new(std::iter::empty(), [0, 0], []).unwrap();
572        assert!(empty.is_empty());
573        assert_eq!(empty.bytes(), 16);
574    }
575
576    #[test]
577    fn zero_identity_default_preserves_external_batch_sampling() {
578        #[derive(Debug)]
579        struct ExternalBatch;
580
581        impl InstanceBatch for ExternalBatch {
582            fn len(&self) -> usize {
583                1
584            }
585
586            fn bounds(&self) -> Rect {
587                Rect::default()
588            }
589
590            fn bytes(&self) -> u64 {
591                0
592            }
593
594            fn work(&self) -> u64 {
595                1
596            }
597
598            fn validate(&self) -> Result<(), ValidationError> {
599                Ok(())
600            }
601
602            fn sample(
603                &self,
604                _: Point,
605                _: f64,
606                mut destination: [f32; 4],
607                space: BlendSpace,
608                composite: InstanceComposite,
609            ) -> Result<[f32; 4], ValidationError> {
610                composite(&mut destination, [0.25, 0.125, 0.0, 0.5], space)?;
611                Ok(destination)
612            }
613
614            fn as_any(&self) -> &dyn Any {
615                self
616            }
617
618            fn content_eq(&self, other: &dyn InstanceBatch) -> bool {
619                other.as_any().is::<Self>()
620            }
621        }
622
623        fn capture(
624            destination: &mut [f32; 4],
625            source: [f32; 4],
626            _: BlendSpace,
627        ) -> Result<(), ValidationError> {
628            if source != [0.0; 4] {
629                *destination = source;
630            }
631            Ok(())
632        }
633        let batch: &dyn InstanceBatch = &ExternalBatch;
634        assert_eq!(
635            batch.sample_with_zero_identity(
636                Point::ORIGIN,
637                1.0,
638                [0.0; 4],
639                BlendSpace::LinearRgb,
640                capture
641            ),
642            batch.sample(Point::ORIGIN, 1.0, [0.0; 4], BlendSpace::LinearRgb, capture)
643        );
644        assert_eq!(
645            batch
646                .sample_with_zero_identity(Point::ORIGIN, 1.0, [0.0; 4], BlendSpace::Srgb, capture)
647                .unwrap(),
648            [0.25, 0.125, 0.0, 0.5]
649        );
650    }
651}