Skip to main content

astrelis_render/
sprite.rs

1//! Sprite sheet support for animations and sprite-based rendering.
2//!
3//! A sprite sheet (also known as a texture atlas with uniform cells) contains
4//! multiple sprites arranged in a grid, all with the same dimensions.
5//! This is commonly used for:
6//! - Character animations
7//! - Loading spinners
8//! - Progress bar segments
9//! - Tile sets
10
11use crate::context::GraphicsContext;
12
13/// A sprite sheet containing uniformly-sized sprites in a grid layout.
14///
15/// All sprites in a sprite sheet have the same dimensions and are arranged
16/// in rows and columns. This makes it efficient for animations where each
17/// frame is the same size.
18///
19/// # Example
20///
21/// ```ignore
22/// // Create a sprite sheet from a texture
23/// let sprite_sheet = SpriteSheet::new(
24///     context,
25///     texture,
26///     SpriteSheetDescriptor {
27///         sprite_width: 32,
28///         sprite_height: 32,
29///         columns: 8,
30///         rows: 4,
31///         ..Default::default()
32///     },
33/// );
34///
35/// // Get UV coordinates for sprite at index 5
36/// let uv = sprite_sheet.sprite_uv(5);
37///
38/// // Or by row/column
39/// let uv = sprite_sheet.sprite_uv_at(1, 2);
40/// ```
41#[derive(Debug)]
42pub struct SpriteSheet {
43    texture: wgpu::Texture,
44    view: wgpu::TextureView,
45    /// Width of each sprite in pixels
46    sprite_width: u32,
47    /// Height of each sprite in pixels
48    sprite_height: u32,
49    /// Number of columns in the grid
50    columns: u32,
51    /// Number of rows in the grid
52    rows: u32,
53    /// Total texture width
54    texture_width: u32,
55    /// Total texture height
56    texture_height: u32,
57    /// Padding between sprites (in pixels)
58    padding: u32,
59    /// Margin around the entire sheet (in pixels)
60    margin: u32,
61}
62
63/// Descriptor for creating a sprite sheet.
64#[derive(Debug, Clone)]
65pub struct SpriteSheetDescriptor {
66    /// Width of each sprite in pixels
67    pub sprite_width: u32,
68    /// Height of each sprite in pixels
69    pub sprite_height: u32,
70    /// Number of sprite columns
71    pub columns: u32,
72    /// Number of sprite rows
73    pub rows: u32,
74    /// Padding between sprites (default: 0)
75    pub padding: u32,
76    /// Margin around the entire sheet (default: 0)
77    pub margin: u32,
78}
79
80impl Default for SpriteSheetDescriptor {
81    fn default() -> Self {
82        Self {
83            sprite_width: 32,
84            sprite_height: 32,
85            columns: 1,
86            rows: 1,
87            padding: 0,
88            margin: 0,
89        }
90    }
91}
92
93/// UV coordinates for a sprite (normalized 0-1 range).
94#[derive(Debug, Clone, Copy, PartialEq)]
95pub struct SpriteUV {
96    /// U coordinate of the left edge
97    pub u_min: f32,
98    /// V coordinate of the top edge
99    pub v_min: f32,
100    /// U coordinate of the right edge
101    pub u_max: f32,
102    /// V coordinate of the bottom edge
103    pub v_max: f32,
104}
105
106impl SpriteUV {
107    /// Create new sprite UV coordinates.
108    pub fn new(u_min: f32, v_min: f32, u_max: f32, v_max: f32) -> Self {
109        Self {
110            u_min,
111            v_min,
112            u_max,
113            v_max,
114        }
115    }
116
117    /// Get UV coordinates as arrays for shader upload.
118    pub fn as_arrays(&self) -> ([f32; 2], [f32; 2]) {
119        ([self.u_min, self.v_min], [self.u_max, self.v_max])
120    }
121
122    /// Flip the sprite horizontally.
123    pub fn flip_horizontal(&self) -> Self {
124        Self {
125            u_min: self.u_max,
126            v_min: self.v_min,
127            u_max: self.u_min,
128            v_max: self.v_max,
129        }
130    }
131
132    /// Flip the sprite vertically.
133    pub fn flip_vertical(&self) -> Self {
134        Self {
135            u_min: self.u_min,
136            v_min: self.v_max,
137            u_max: self.u_max,
138            v_max: self.v_min,
139        }
140    }
141}
142
143impl SpriteSheet {
144    /// Create a new sprite sheet from an existing texture.
145    pub fn new(
146        texture: wgpu::Texture,
147        view: wgpu::TextureView,
148        texture_width: u32,
149        texture_height: u32,
150        descriptor: SpriteSheetDescriptor,
151    ) -> Self {
152        Self {
153            texture,
154            view,
155            sprite_width: descriptor.sprite_width,
156            sprite_height: descriptor.sprite_height,
157            columns: descriptor.columns,
158            rows: descriptor.rows,
159            texture_width,
160            texture_height,
161            padding: descriptor.padding,
162            margin: descriptor.margin,
163        }
164    }
165
166    /// Create a sprite sheet from raw pixel data.
167    ///
168    /// # Arguments
169    ///
170    /// * `context` - Graphics context
171    /// * `data` - Raw RGBA pixel data
172    /// * `texture_width` - Width of the texture in pixels
173    /// * `texture_height` - Height of the texture in pixels
174    /// * `descriptor` - Sprite sheet configuration
175    pub fn from_data(
176        context: &GraphicsContext,
177        data: &[u8],
178        texture_width: u32,
179        texture_height: u32,
180        descriptor: SpriteSheetDescriptor,
181    ) -> Self {
182        let size = wgpu::Extent3d {
183            width: texture_width,
184            height: texture_height,
185            depth_or_array_layers: 1,
186        };
187
188        let texture = context.device().create_texture(&wgpu::TextureDescriptor {
189            label: Some("Sprite Sheet Texture"),
190            size,
191            mip_level_count: 1,
192            sample_count: 1,
193            dimension: wgpu::TextureDimension::D2,
194            format: wgpu::TextureFormat::Rgba8UnormSrgb,
195            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
196            view_formats: &[],
197        });
198
199        context.queue().write_texture(
200            wgpu::TexelCopyTextureInfo {
201                texture: &texture,
202                mip_level: 0,
203                origin: wgpu::Origin3d::ZERO,
204                aspect: wgpu::TextureAspect::All,
205            },
206            data,
207            wgpu::TexelCopyBufferLayout {
208                offset: 0,
209                bytes_per_row: Some(texture_width * 4),
210                rows_per_image: Some(texture_height),
211            },
212            size,
213        );
214
215        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
216
217        Self::new(texture, view, texture_width, texture_height, descriptor)
218    }
219
220    /// Get UV coordinates for a sprite by linear index.
221    ///
222    /// Sprites are indexed left-to-right, top-to-bottom, starting from 0.
223    pub fn sprite_uv(&self, index: u32) -> SpriteUV {
224        let col = index % self.columns;
225        let row = index / self.columns;
226        self.sprite_uv_at(row, col)
227    }
228
229    /// Get UV coordinates for a sprite by row and column.
230    pub fn sprite_uv_at(&self, row: u32, col: u32) -> SpriteUV {
231        // Calculate pixel coordinates
232        let x = self.margin + col * (self.sprite_width + self.padding);
233        let y = self.margin + row * (self.sprite_height + self.padding);
234
235        // Convert to normalized UV coordinates
236        let u_min = x as f32 / self.texture_width as f32;
237        let v_min = y as f32 / self.texture_height as f32;
238        let u_max = (x + self.sprite_width) as f32 / self.texture_width as f32;
239        let v_max = (y + self.sprite_height) as f32 / self.texture_height as f32;
240
241        SpriteUV {
242            u_min,
243            v_min,
244            u_max,
245            v_max,
246        }
247    }
248
249    /// Get the total number of sprites in the sheet.
250    pub fn sprite_count(&self) -> u32 {
251        self.columns * self.rows
252    }
253
254    /// Get the sprite dimensions in pixels.
255    pub fn sprite_size(&self) -> (u32, u32) {
256        (self.sprite_width, self.sprite_height)
257    }
258
259    /// Get the grid dimensions (columns, rows).
260    pub fn grid_size(&self) -> (u32, u32) {
261        (self.columns, self.rows)
262    }
263
264    /// Get the texture view for binding.
265    pub fn view(&self) -> &wgpu::TextureView {
266        &self.view
267    }
268
269    /// Get the underlying texture.
270    pub fn texture(&self) -> &wgpu::Texture {
271        &self.texture
272    }
273
274    /// Get texture dimensions.
275    pub fn texture_size(&self) -> (u32, u32) {
276        (self.texture_width, self.texture_height)
277    }
278}
279
280/// Animation state for cycling through sprite sheet frames.
281#[derive(Debug, Clone)]
282pub struct SpriteAnimation {
283    /// First frame index (inclusive)
284    start_frame: u32,
285    /// Last frame index (inclusive)
286    end_frame: u32,
287    /// Current frame index
288    current_frame: u32,
289    /// Time per frame in seconds
290    frame_duration: f32,
291    /// Time accumulated since last frame change
292    elapsed: f32,
293    /// Whether the animation loops
294    looping: bool,
295    /// Whether the animation is playing
296    playing: bool,
297    /// Direction (1 = forward, -1 = backward)
298    direction: i32,
299}
300
301impl SpriteAnimation {
302    /// Create a new animation for all frames.
303    pub fn new(total_frames: u32, fps: f32) -> Self {
304        Self {
305            start_frame: 0,
306            end_frame: total_frames.saturating_sub(1),
307            current_frame: 0,
308            frame_duration: 1.0 / fps,
309            elapsed: 0.0,
310            looping: true,
311            playing: true,
312            direction: 1,
313        }
314    }
315
316    /// Create an animation for a range of frames.
317    pub fn with_range(start: u32, end: u32, fps: f32) -> Self {
318        Self {
319            start_frame: start,
320            end_frame: end,
321            current_frame: start,
322            frame_duration: 1.0 / fps,
323            elapsed: 0.0,
324            looping: true,
325            playing: true,
326            direction: 1,
327        }
328    }
329
330    /// Set whether the animation loops.
331    pub fn looping(mut self, looping: bool) -> Self {
332        self.looping = looping;
333        self
334    }
335
336    /// Update the animation with elapsed time.
337    ///
338    /// Returns true if the frame changed.
339    pub fn update(&mut self, dt: f32) -> bool {
340        if !self.playing {
341            return false;
342        }
343
344        self.elapsed += dt;
345
346        if self.elapsed >= self.frame_duration {
347            self.elapsed -= self.frame_duration;
348
349            let frame_count = self.end_frame - self.start_frame + 1;
350            let relative_frame = self.current_frame - self.start_frame;
351            let new_relative = (relative_frame as i32 + self.direction) as u32;
352
353            if new_relative >= frame_count {
354                if self.looping {
355                    self.current_frame = self.start_frame;
356                } else {
357                    self.playing = false;
358                    self.current_frame = self.end_frame;
359                }
360            } else {
361                self.current_frame = self.start_frame + new_relative;
362            }
363
364            return true;
365        }
366
367        false
368    }
369
370    /// Get the current frame index.
371    pub fn current_frame(&self) -> u32 {
372        self.current_frame
373    }
374
375    /// Jump to a specific frame.
376    pub fn set_frame(&mut self, frame: u32) {
377        self.current_frame = frame.clamp(self.start_frame, self.end_frame);
378        self.elapsed = 0.0;
379    }
380
381    /// Play the animation.
382    pub fn play(&mut self) {
383        self.playing = true;
384    }
385
386    /// Pause the animation.
387    pub fn pause(&mut self) {
388        self.playing = false;
389    }
390
391    /// Stop and reset the animation.
392    pub fn stop(&mut self) {
393        self.playing = false;
394        self.current_frame = self.start_frame;
395        self.elapsed = 0.0;
396    }
397
398    /// Check if the animation is playing.
399    pub fn is_playing(&self) -> bool {
400        self.playing
401    }
402
403    /// Check if the animation has finished (only relevant for non-looping).
404    pub fn is_finished(&self) -> bool {
405        !self.looping && !self.playing && self.current_frame == self.end_frame
406    }
407
408    /// Reverse the animation direction.
409    pub fn reverse(&mut self) {
410        self.direction = -self.direction;
411    }
412
413    /// Set the playback speed (fps).
414    pub fn set_fps(&mut self, fps: f32) {
415        self.frame_duration = 1.0 / fps;
416    }
417
418    /// Get normalized progress (0.0 to 1.0).
419    pub fn progress(&self) -> f32 {
420        let frame_count = self.end_frame - self.start_frame + 1;
421        if frame_count <= 1 {
422            return 1.0;
423        }
424        (self.current_frame - self.start_frame) as f32 / (frame_count - 1) as f32
425    }
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431
432    #[test]
433    fn test_sprite_uv_calculation() {
434        // 4x4 sprites, 32x32 each, in a 128x128 texture
435        let descriptor = SpriteSheetDescriptor {
436            sprite_width: 32,
437            sprite_height: 32,
438            columns: 4,
439            rows: 4,
440            padding: 0,
441            margin: 0,
442        };
443
444        // We can't actually create a sprite sheet without a GPU context,
445        // but we can test the UV calculation logic
446        let uv = calculate_sprite_uv(&descriptor, 128, 128, 0, 0);
447        assert_eq!(uv.u_min, 0.0);
448        assert_eq!(uv.v_min, 0.0);
449        assert_eq!(uv.u_max, 0.25);
450        assert_eq!(uv.v_max, 0.25);
451
452        let uv = calculate_sprite_uv(&descriptor, 128, 128, 1, 1);
453        assert_eq!(uv.u_min, 0.25);
454        assert_eq!(uv.v_min, 0.25);
455        assert_eq!(uv.u_max, 0.5);
456        assert_eq!(uv.v_max, 0.5);
457    }
458
459    fn calculate_sprite_uv(
460        desc: &SpriteSheetDescriptor,
461        tex_w: u32,
462        tex_h: u32,
463        row: u32,
464        col: u32,
465    ) -> SpriteUV {
466        let x = desc.margin + col * (desc.sprite_width + desc.padding);
467        let y = desc.margin + row * (desc.sprite_height + desc.padding);
468
469        SpriteUV {
470            u_min: x as f32 / tex_w as f32,
471            v_min: y as f32 / tex_h as f32,
472            u_max: (x + desc.sprite_width) as f32 / tex_w as f32,
473            v_max: (y + desc.sprite_height) as f32 / tex_h as f32,
474        }
475    }
476
477    #[test]
478    fn test_animation_basic() {
479        let mut anim = SpriteAnimation::new(4, 10.0);
480        assert_eq!(anim.current_frame(), 0);
481
482        // Advance one frame
483        assert!(anim.update(0.1));
484        assert_eq!(anim.current_frame(), 1);
485
486        // Advance to end
487        anim.update(0.1);
488        anim.update(0.1);
489        anim.update(0.1);
490        assert_eq!(anim.current_frame(), 0); // Looped back
491    }
492
493    #[test]
494    fn test_animation_no_loop() {
495        let mut anim = SpriteAnimation::new(3, 10.0).looping(false);
496
497        anim.update(0.1);
498        anim.update(0.1);
499        anim.update(0.1);
500
501        assert!(anim.is_finished());
502        assert_eq!(anim.current_frame(), 2);
503    }
504
505    #[test]
506    fn test_uv_flip() {
507        let uv = SpriteUV::new(0.0, 0.0, 0.5, 0.5);
508
509        let flipped_h = uv.flip_horizontal();
510        assert_eq!(flipped_h.u_min, 0.5);
511        assert_eq!(flipped_h.u_max, 0.0);
512
513        let flipped_v = uv.flip_vertical();
514        assert_eq!(flipped_v.v_min, 0.5);
515        assert_eq!(flipped_v.v_max, 0.0);
516    }
517}