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