Skip to main content

motion_core/instances/
analytic.rs

1//! Checked seeded random-access trajectory programs, independent of graphics APIs.
2
3use super::{InstanceKind, MAX_INSTANCE_COUNT};
4use crate::ValidationError;
5use std::sync::Arc;
6
7/// Version-one analytic header length, in four-byte words.
8pub const ANALYTIC_HEADER_WORDS: usize = 20;
9
10/// Version-one initial-state stride, in four-byte words.
11pub const ANALYTIC_STATE_WORDS: usize = 8;
12
13/// Longest admitted analytic age and lifetime, in seconds.
14const MAX_ANALYTIC_SECONDS: f32 = 3600.0;
15
16/// Largest absolute model parameter or initial spatial value admitted to f32 evaluation.
17const MAX_ANALYTIC_VALUE: f32 = 1e6;
18
19/// Largest resolved temporal or noise lattice phase in the portable f32 contract.
20const MAX_ANALYTIC_PHASE: f32 = 8192.0;
21
22/// Closed analytic models; fixed-step simulations are deliberately not represented.
23#[derive(Clone, Copy, Debug, PartialEq, Eq)]
24#[repr(u32)]
25pub enum AnalyticTrajectoryKind {
26    /// Constant acceleration with coupled linear damping.
27    Ballistic = 1,
28    /// Smoothstep interpolation from position to target.
29    Converge,
30    /// Circular motion with seeded initial phase.
31    Orbit,
32    /// Linear drift plus quintic value-noise displacement.
33    NoiseDrift,
34}
35
36/// Seeded initial facts for one stable particle, before evaluating time.
37#[derive(Clone, Copy, Debug, PartialEq)]
38pub struct AnalyticInstanceState {
39    /// Position at birth.
40    pub position: [f32; 2],
41    /// Velocity at birth.
42    pub velocity: [f32; 2],
43    /// Target coordinates, orbital phase, or independent noise phases.
44    pub custom: [f32; 2],
45    /// Stable particle ID, used as the noise lattice key.
46    pub index: u32,
47    /// Initial sprite angle in radians.
48    pub rotation: f32,
49}
50
51/// Immutable bounded analytic program evaluated into ordinary twelve-word primitives.
52///
53/// The seed identifies the keyed initial-state stream; initial facts are explicitly supplied
54/// rather than depending on a renderer's random implementation. Time is age in seconds.
55/// Colors, sizes, images and blend policy remain in the ordinary [`super::InstanceUpload`].
56#[derive(Clone, Debug, PartialEq)]
57pub struct AnalyticInstanceUpload {
58    words: Arc<[f32]>,
59}
60
61impl AnalyticInstanceUpload {
62    /// Validates a model, explicit seed, age, lifetime, parameters and initial states.
63    ///
64    /// Parameters use the builtin models' eight-word schema: ballistic origin/acceleration/
65    /// damping/reserved/speed range; converge reserves them; orbit center/radius/angular
66    /// velocity; noise drift origin/velocity/amplitude/frequency. Unsupported precision
67    /// ranges fail explicitly, allowing a caller to retain evaluated CPU primitives.
68    ///
69    /// Returns [`crate::ValidationErrorKind::Unsupported`] outside the portable f32 parameter
70    /// and phase range, [`crate::ValidationErrorKind::InvalidValue`] for malformed inputs,
71    /// and [`crate::ValidationErrorKind::StructuralLimit`] when upload allocation fails.
72    #[expect(
73        clippy::too_many_arguments,
74        reason = "The versioned model has explicit time, seed and primitive inputs"
75    )]
76    pub fn new(
77        kind: AnalyticTrajectoryKind,
78        seed: u64,
79        age: f32,
80        lifetime: f32,
81        parameters: [f32; 8],
82        primitive: InstanceKind,
83        align_to_velocity: bool,
84        states: impl ExactSizeIterator<Item = AnalyticInstanceState>,
85    ) -> Result<Self, ValidationError> {
86        let invalid = || {
87            ValidationError::invalid(
88                "instances.analytic",
89                "program",
90                "Invalid analytic model, time, initial state or precision range",
91            )
92        };
93        let count = states.len();
94        if count > MAX_INSTANCE_COUNT
95            || !age.is_finite()
96            || !lifetime.is_finite()
97            || !(0.0..=MAX_ANALYTIC_SECONDS).contains(&age)
98            || lifetime <= 0.0
99            || lifetime > MAX_ANALYTIC_SECONDS
100            || age >= lifetime
101            || parameters.iter().any(|value| !value.is_finite())
102            || !matches!(
103                primitive,
104                InstanceKind::Disc | InstanceKind::Glow | InstanceKind::Sprite
105            )
106            || (kind == AnalyticTrajectoryKind::Ballistic
107                && (parameters[4] < 0.0 || parameters[6] < 0.0 || parameters[7] < parameters[6]))
108            || (kind == AnalyticTrajectoryKind::Orbit && parameters[2] < 0.0)
109            || (kind == AnalyticTrajectoryKind::NoiseDrift
110                && (parameters[4] < 0.0 || parameters[5] < 0.0))
111        {
112            return Err(invalid());
113        }
114        let precision = || {
115            ValidationError::unsupported(
116                "instances.analytic",
117                "precision",
118                "Trajectory exceeds the bounded f32 analytic precision range",
119            )
120        };
121        if parameters
122            .iter()
123            .any(|value| value.abs() > MAX_ANALYTIC_VALUE)
124            || (kind == AnalyticTrajectoryKind::Orbit
125                && (parameters[3] * age).abs() > MAX_ANALYTIC_PHASE)
126            || (kind == AnalyticTrajectoryKind::NoiseDrift
127                && parameters[5] * age > MAX_ANALYTIC_PHASE)
128        {
129            return Err(precision());
130        }
131        let expected = ANALYTIC_HEADER_WORDS + count * ANALYTIC_STATE_WORDS;
132        let mut words = Vec::new();
133        words.try_reserve_exact(expected).map_err(|_| {
134            ValidationError::limit(
135                "instances.analytic",
136                "max_bytes",
137                "Could not reserve analytic upload",
138            )
139        })?;
140        words.extend([
141            kind as u32 as f32,
142            age,
143            lifetime,
144            primitive as u32 as f32,
145            u32::from(align_to_velocity) as f32,
146            count as f32,
147            f32::from_bits(seed as u32),
148            f32::from_bits((seed >> 32) as u32),
149        ]);
150        words.extend(parameters);
151        words.extend([0.0; 4]);
152        for state in states {
153            if words.len() >= expected
154                || state
155                    .position
156                    .into_iter()
157                    .chain(state.velocity)
158                    .chain(state.custom)
159                    .chain([state.rotation])
160                    .any(|value| !value.is_finite())
161            {
162                return Err(invalid());
163            }
164            if state
165                .position
166                .into_iter()
167                .chain(state.velocity)
168                .chain(state.custom)
169                .chain([state.rotation])
170                .any(|value| value.abs() > MAX_ANALYTIC_VALUE)
171                || (kind == AnalyticTrajectoryKind::NoiseDrift
172                    && state
173                        .custom
174                        .iter()
175                        .any(|value| value.abs() > MAX_ANALYTIC_PHASE))
176            {
177                return Err(precision());
178            }
179            words.extend(state.position);
180            words.extend(state.velocity);
181            words.extend(state.custom);
182            words.extend([f32::from_bits(state.index), state.rotation]);
183        }
184        if words.len() != expected {
185            return Err(invalid());
186        }
187        Ok(Self {
188            words: words.into(),
189        })
190    }
191
192    /// Number of ordinary primitives produced by evaluation.
193    pub fn len(&self) -> usize {
194        self.words[5] as usize
195    }
196
197    /// Whether this program has no initial states.
198    pub fn is_empty(&self) -> bool {
199        self.len() == 0
200    }
201
202    /// Exact immutable upload size in bytes.
203    pub fn bytes(&self) -> u64 {
204        self.words.len() as u64 * 4
205    }
206
207    /// Version-one program words; seed and IDs are integer bit patterns.
208    pub fn words(&self) -> &[f32] {
209        &self.words
210    }
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216
217    #[test]
218    fn analytic_upload_validates_time_finiteness_and_exact_layout() {
219        let state = AnalyticInstanceState {
220            position: [0.0; 2],
221            velocity: [1.0; 2],
222            custom: [0.0; 2],
223            index: u32::MAX,
224            rotation: 0.0,
225        };
226        let make = |age, parameters, state| {
227            AnalyticInstanceUpload::new(
228                AnalyticTrajectoryKind::Ballistic,
229                u64::MAX,
230                age,
231                2.0,
232                parameters,
233                InstanceKind::Disc,
234                false,
235                [state].into_iter(),
236            )
237        };
238        let upload = make(1.0, [0.0; 8], state).unwrap();
239        assert_eq!(upload.len(), 1);
240        assert_eq!(
241            upload.bytes(),
242            ((ANALYTIC_HEADER_WORDS + ANALYTIC_STATE_WORDS) * 4) as u64
243        );
244        assert_eq!(upload.words()[6].to_bits(), u32::MAX);
245        assert!(make(2.0, [0.0; 8], state).is_err());
246        assert!(make(f32::NAN, [0.0; 8], state).is_err());
247        assert!(make(1.0, [f32::INFINITY; 8], state).is_err());
248        assert!(
249            make(
250                1.0,
251                [0.0; 8],
252                AnalyticInstanceState {
253                    rotation: f32::NAN,
254                    ..state
255                }
256            )
257            .is_err()
258        );
259    }
260}