motion_content/particles/
sprites.rs1use super::*;
4
5#[derive(Clone, Copy, Debug, PartialEq)]
7pub enum SpriteSheetMode {
8 OverLifetime,
10 Fps(f64),
12 Random(u64),
14 Custom,
16}
17
18#[derive(Clone, Copy, Debug, PartialEq)]
20pub struct SpriteSheet {
21 pub columns: u16,
23 pub rows: u16,
25 pub frames: u16,
27 pub mode: SpriteSheetMode,
29}
30
31impl SpriteSheet {
32 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#[derive(Clone, Copy, Debug)]
82pub enum ProceduralSprite {
83 SoftCircle,
85 Smoke {
87 scale: f64,
89 evolution: f64,
91 fractal: motion_core::noise::Fractal,
93 },
94 Star,
96 Ring,
98 FireCore,
100}
101
102impl ProceduralSprite {
103 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 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#[derive(Clone)]
206pub struct SpriteAtlas {
207 pub image: Arc<crate::ImageAsset>,
209 pub sheet: SpriteSheet,
211}
212
213impl SpriteAtlas {
214 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 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 pub fn random(mut self, seed: u64) -> Self {
250 self.sheet.mode = SpriteSheetMode::Random(seed);
251 self
252 }
253
254 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}