Skip to main content

motion_content/particles/
sprites.rs

1//! Deterministic procedural sprite materials and bounded immutable atlases.
2
3use super::*;
4
5/// Stateless sprite-frame selection.
6#[derive(Clone, Copy, Debug, PartialEq)]
7pub enum SpriteSheetMode {
8    /// Clamp the normalized lifetime to one authored animation pass.
9    OverLifetime,
10    /// Loop using a finite nonnegative frame rate.
11    Fps(f64),
12    /// Pick once from stable particle.index and an explicit seed.
13    Random(u64),
14    /// Select an atlas cell using `floor(particle.custom[0])`.
15    Custom,
16}
17
18/// Uniform row-major sheet cells. Sampling clamps inside the selected cell.
19#[derive(Clone, Copy, Debug, PartialEq)]
20pub struct SpriteSheet {
21    /// Positive column count.
22    pub columns: u16,
23    /// Positive row count.
24    pub rows: u16,
25    /// Active frames, at most columns*rows.
26    pub frames: u16,
27    /// Pure time/ID selection policy.
28    pub mode: SpriteSheetMode,
29}
30
31impl SpriteSheet {
32    /// Checks layout and computes a frame without mutating particle state.
33    pub fn frame(&self, particle: &Particle) -> Result<u16, ValidationError> {
34        if self.columns == 0
35            || self.rows == 0
36            || self.frames == 0
37            || u32::from(self.frames) > u32::from(self.columns) * u32::from(self.rows)
38        {
39            return Err(invalid(
40                "sheet",
41                "Require positive sheet dimensions and bounded frame count",
42            ));
43        }
44        particle.validate()?;
45        Ok(match self.mode {
46            SpriteSheetMode::OverLifetime => {
47                ((particle.age / particle.lifetime).clamp(0.0, 1.0) * f32::from(self.frames)) as u16
48            }
49            SpriteSheetMode::Fps(fps) => {
50                if !fps.is_finite() || !(0.0..=10000.0).contains(&fps) {
51                    return Err(invalid("sheet", "Frame rate must be finite in 0..=10000"));
52                }
53                (libm::floor(f64::from(particle.age) * fps).rem_euclid(f64::from(self.frames)))
54                    as u16
55            }
56            SpriteSheetMode::Random(seed) => {
57                (Random::for_particle(seed, particle.index, 0).next_float()
58                    * f64::from(self.frames)) as u16
59            }
60            SpriteSheetMode::Custom => {
61                libm::floorf(particle.custom[0]).rem_euclid(f32::from(self.frames)) as u16
62            }
63        }
64        .min(self.frames - 1))
65    }
66
67    pub(super) fn uv(&self, particle: &Particle) -> Result<[f32; 4], ValidationError> {
68        let frame = self.frame(particle)?;
69        let column = f32::from(frame % self.columns);
70        let row = f32::from(frame / self.columns);
71        Ok([
72            column / f32::from(self.columns),
73            row / f32::from(self.rows),
74            (column + 1.0) / f32::from(self.columns),
75            (row + 1.0) / f32::from(self.rows),
76        ])
77    }
78}
79
80/// Built-in image materials requiring no external assets.
81#[derive(Clone, Copy, Debug)]
82pub enum ProceduralSprite {
83    /// Radial soft circular material.
84    SoftCircle,
85    /// Seeded smoke: spatial fBm over a time-evolving three-dimensional Perlin field.
86    Smoke {
87        /// Positive spatial scale in normalized sprite coordinates, in 0.25..=64.
88        scale: f64,
89        /// Signed time evolution speed.
90        evolution: f64,
91        /// Bounded spatial octave recipe.
92        fractal: motion_core::noise::Fractal,
93    },
94    /// Cross-shaped star highlight.
95    Star,
96    /// Soft annular material.
97    Ring,
98    /// Vertically elongated warm fire core.
99    FireCore,
100}
101
102impl ProceduralSprite {
103    /// Builds a finite linear sprite; size is 2..=512 and all noise uses an explicit seed.
104    pub fn generate(self, size: u32, seed: u64) -> Result<Arc<crate::ImageAsset>, ValidationError> {
105        self.generate_at(size, seed, Time::ZERO, motion_core::RasterLimits::default())
106    }
107
108    /// Builds a sprite at explicit time after pixel, mip storage and work admission.
109    /// Only `Smoke` depends on time; the other materials are static.
110    pub fn generate_at(
111        self,
112        size: u32,
113        seed: u64,
114        time: Time,
115        limits: motion_core::RasterLimits,
116    ) -> Result<Arc<crate::ImageAsset>, ValidationError> {
117        if !(2..=512).contains(&size) {
118            return Err(invalid(
119                "sprite",
120                "Procedural sprite size must be in 2..=512",
121            ));
122        }
123        let factor = if let Self::Smoke {
124            scale,
125            evolution,
126            fractal,
127        } = self
128        {
129            fractal.validate()?;
130            let phase = time.as_seconds() * evolution;
131            if !scale.is_finite()
132                || !(0.25..=64.0).contains(&scale)
133                || !evolution.is_finite()
134                || !phase.is_finite()
135                || phase < f64::from(i32::MIN)
136                || phase >= f64::from(i32::MAX)
137            {
138                return Err(invalid(
139                    "sprite",
140                    "Require bounded smoke scale, finite supported phase and fractal",
141                ));
142            }
143            u64::from(fractal.octaves) * 320
144        } else {
145            32
146        };
147        let pixels_count = u64::from(size) * u64::from(size);
148        let mut mip_size = size;
149        let mut mip_pixels = pixels_count;
150        while mip_size > 1 {
151            mip_size = mip_size.div_ceil(2);
152            mip_pixels += u64::from(mip_size).pow(2);
153        }
154        let mut budget =
155            motion_core::budget::Budget::new(motion_core::PixelSize::new(size, size), limits)?;
156        budget.bytes(pixels_count * 32 + mip_pixels * 16, "particles.sprite")?;
157        budget.work(pixels_count * factor + mip_pixels * 128, "particles.sprite")?;
158        let mut pixels = Vec::new();
159        pixels
160            .try_reserve_exact((size * size) as usize)
161            .map_err(|_| limit("Sprite allocation failed"))?;
162        for row in 0..size {
163            for column in 0..size {
164                let horizontal = (f64::from(column) + 0.5) / f64::from(size) * 2.0 - 1.0;
165                let vertical = (f64::from(row) + 0.5) / f64::from(size) * 2.0 - 1.0;
166                let distance = libm::hypot(horizontal, vertical);
167                let alpha = match self {
168                    Self::SoftCircle => (1.0 - distance).max(0.0).powi(2),
169                    Self::Smoke {
170                        scale,
171                        evolution,
172                        fractal,
173                    } => {
174                        let phase = time.as_seconds() * evolution;
175                        let seed = seed as u32 ^ (seed >> 32) as u32;
176                        let field = motion_core::noise::fbm(
177                            |point| motion_core::noise::perlin3([point[0], point[1], phase], seed),
178                            [horizontal * scale, vertical * scale],
179                            fractal,
180                        )?;
181                        (1.0 - distance).max(0.0).powi(2) * (0.65 + 0.35 * field).clamp(0.0, 1.0)
182                    }
183                    Self::Star => ((1.0 - distance)
184                        / (0.15 + horizontal.abs().min(vertical.abs()) * 8.0))
185                        .clamp(0.0, 1.0),
186                    Self::Ring => (1.0 - (distance - 0.7).abs() * 12.0).clamp(0.0, 1.0),
187                    Self::FireCore => (1.0 - libm::hypot(horizontal * 1.6, vertical))
188                        .max(0.0)
189                        .powi(2),
190                };
191                pixels.push(if matches!(self, Self::FireCore) {
192                    [1.0, 0.4 + alpha * 0.6, alpha * 0.4, alpha]
193                } else {
194                    [1.0, 1.0, 1.0, alpha]
195                });
196            }
197        }
198        crate::ImageAsset::from_rgba_float(size, size, pixels)
199            .map(Arc::new)
200            .map_err(|_| invalid("sprite", "Cannot construct finite sprite material"))
201    }
202}
203
204/// An immutable, uniformly sized atlas; unused cells are transparent.
205#[derive(Clone)]
206pub struct SpriteAtlas {
207    /// Single GPU/CPU shared material.
208    pub image: Arc<crate::ImageAsset>,
209    /// Selects cells from `custom[0]`.
210    pub sheet: SpriteSheet,
211}
212
213impl SpriteAtlas {
214    /// Packs 1..=64 equally sized images under a conservative construction/mipmap budget.
215    pub fn new(images: &[Arc<crate::ImageAsset>], max_bytes: u64) -> Result<Self, ValidationError> {
216        let Some(first) = images.first() else {
217            return Err(invalid("atlas", "Require 1..=64 images"));
218        };
219        let cell = [first.width(), first.height()];
220        if images
221            .iter()
222            .any(|image| [image.width(), image.height()] != cell)
223        {
224            return Err(invalid("atlas", "Atlas cells must have equal dimensions"));
225        }
226        Self::build(images, cell, max_bytes)
227    }
228
229    /// Packs 1..=64 images of any size: every cell is as large as the biggest
230    /// image and smaller images are centred on transparent padding, so the
231    /// uniform-cell sheet protocol (and CPU/GPU sampling) is unchanged. Sprites
232    /// keep their relative pixel scale; a sprite drawn at `size` spans the whole
233    /// cell.
234    pub fn new_padded(
235        images: &[Arc<crate::ImageAsset>],
236        max_bytes: u64,
237    ) -> Result<Self, ValidationError> {
238        if images.is_empty() {
239            return Err(invalid("atlas", "Require 1..=64 images"));
240        }
241        let cell = images.iter().fold([0, 0], |cell, image| {
242            [cell[0].max(image.width()), cell[1].max(image.height())]
243        });
244        Self::build(images, cell, max_bytes)
245    }
246
247    /// Selects sub-images by stable particle index and `seed`, so a particle keeps
248    /// the same texture for its whole life.
249    pub fn random(mut self, seed: u64) -> Self {
250        self.sheet.mode = SpriteSheetMode::Random(seed);
251        self
252    }
253
254    /// Builds a `Sprites` style that selects cells according to `self.sheet.mode`
255    /// (`Custom` reads `particle.custom[0]`) and carries the sheet already.
256    pub fn style(
257        &self,
258        size: f64,
259        align_to_velocity: bool,
260    ) -> Result<ParticleStyle, ValidationError> {
261        let (width, height) = (self.image.width(), self.image.height());
262        let mut hash = 0xcbf29ce484222325_u64;
263        let level = &self.image.levels()[0];
264        for value in [width, height, u32::from(self.sheet.frames)]
265            .into_iter()
266            .chain(
267                [level.pixel(0, 0), level.pixel(width / 2, height / 2)]
268                    .into_iter()
269                    .flatten()
270                    .flatten()
271                    .map(f32::to_bits),
272            )
273        {
274            hash = (hash ^ u64::from(value)).wrapping_mul(0x100000001b3);
275        }
276        let image = motion_core::Assets::default()
277            .preload(
278                motion_core::Source::Bytes {
279                    key: format!("particle-atlas:{width}x{height}:{hash:016x}").into(),
280                    data: Arc::from([]),
281                },
282                self.image.clone(),
283            )
284            .map_err(|_| invalid("atlas", "Atlas exceeds resource allowance"))?;
285        Ok(ParticleStyle::Sprites {
286            image,
287            size,
288            align_to_velocity,
289        }
290        .sheet(self.sheet))
291    }
292
293    fn build(
294        images: &[Arc<crate::ImageAsset>],
295        cell: [u32; 2],
296        max_bytes: u64,
297    ) -> Result<Self, ValidationError> {
298        if images.is_empty() || images.len() > 64 {
299            return Err(invalid("atlas", "Require 1..=64 images"));
300        }
301        let columns = libm::ceil(libm::sqrt(images.len() as f64)) as u16;
302        let rows = (images.len() as u16).div_ceil(columns);
303        let width = cell[0]
304            .checked_mul(u32::from(columns))
305            .ok_or_else(|| limit("Atlas width overflow"))?;
306        let height = cell[1]
307            .checked_mul(u32::from(rows))
308            .ok_or_else(|| limit("Atlas height overflow"))?;
309        if width > 4096 || height > 4096 || u64::from(width) * u64::from(height) * 64 > max_bytes {
310            return Err(limit("Atlas construction exceeds size/byte allowance"));
311        }
312        let mut pixels = Vec::new();
313        pixels
314            .try_reserve_exact((width * height) as usize)
315            .map_err(|_| limit("Atlas allocation failed"))?;
316        pixels.resize((width * height) as usize, [0.0; 4]);
317        for (index, image) in images.iter().enumerate() {
318            let offset = [
319                (cell[0] - image.width()) / 2,
320                (cell[1] - image.height()) / 2,
321            ];
322            for row in 0..image.height() {
323                for column in 0..image.width() {
324                    let color = image.levels()[0]
325                        .pixel(column, row)
326                        .ok_or_else(|| invalid("atlas", "Missing image pixel"))?;
327                    let alpha = f64::from(color[3]);
328                    let target = ((index as u32 / u32::from(columns) * cell[1] + offset[1] + row)
329                        * width
330                        + index as u32 % u32::from(columns) * cell[0]
331                        + offset[0]
332                        + column) as usize;
333                    let straight = |channel: f32| {
334                        if alpha == 0.0 {
335                            0.0
336                        } else {
337                            f64::from(channel) / alpha
338                        }
339                    };
340                    pixels[target] = [
341                        straight(color[0]),
342                        straight(color[1]),
343                        straight(color[2]),
344                        alpha,
345                    ];
346                }
347            }
348        }
349        let image = crate::ImageAsset::from_rgba_float(width, height, pixels)
350            .map(Arc::new)
351            .map_err(|_| invalid("atlas", "Invalid atlas material"))?;
352        Ok(Self {
353            image,
354            sheet: SpriteSheet {
355                columns,
356                rows,
357                frames: images.len() as u16,
358                mode: SpriteSheetMode::Custom,
359            },
360        })
361    }
362}