Skip to main content

sprite_sheet/
sprite_sheet.rs

1//! Sprite sheet example demonstrating animated sprites.
2//!
3//! This example shows how to:
4//! - Create a sprite sheet from procedurally generated data
5//! - Animate through sprite frames
6//! - Render sprites with proper UV coordinates
7//!
8//! The example creates a simple 4-frame "spinning" animation.
9
10use astrelis_core::logging;
11use astrelis_render::{
12    Color, GraphicsContext, RenderTarget, RenderableWindow, WindowContextDescriptor,
13    SpriteSheet, SpriteSheetDescriptor, SpriteAnimation,
14};
15use astrelis_winit::{
16    WindowId,
17    app::run_app,
18    window::{WindowBackend, WindowDescriptor, Window, WinitPhysicalSize},
19};
20use std::collections::HashMap;
21use std::time::Instant;
22use wgpu::util::DeviceExt;
23use std::sync::Arc;
24
25/// WGSL shader for rendering sprites
26const SHADER: &str = r#"
27struct Uniforms {
28    mvp: mat4x4<f32>,
29}
30
31@group(0) @binding(0) var<uniform> uniforms: Uniforms;
32@group(0) @binding(1) var sprite_texture: texture_2d<f32>;
33@group(0) @binding(2) var sprite_sampler: sampler;
34
35struct VertexInput {
36    @location(0) position: vec2<f32>,
37    @location(1) uv: vec2<f32>,
38}
39
40struct VertexOutput {
41    @builtin(position) clip_position: vec4<f32>,
42    @location(0) uv: vec2<f32>,
43}
44
45@vertex
46fn vs_main(in: VertexInput) -> VertexOutput {
47    var out: VertexOutput;
48    out.clip_position = uniforms.mvp * vec4<f32>(in.position, 0.0, 1.0);
49    out.uv = in.uv;
50    return out;
51}
52
53@fragment
54fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
55    return textureSample(sprite_texture, sprite_sampler, in.uv);
56}
57"#;
58
59#[repr(C)]
60#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
61struct Vertex {
62    position: [f32; 2],
63    uv: [f32; 2],
64}
65
66#[repr(C)]
67#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
68struct Uniforms {
69    mvp: [[f32; 4]; 4],
70}
71
72/// Generate a 4-frame sprite sheet with a spinning indicator.
73fn generate_sprite_sheet_data() -> (Vec<u8>, u32, u32) {
74    const SPRITE_SIZE: u32 = 64;
75    const COLUMNS: u32 = 4;
76    const ROWS: u32 = 1;
77    
78    let width = SPRITE_SIZE * COLUMNS;
79    let height = SPRITE_SIZE * ROWS;
80    let mut pixels = vec![0u8; (width * height * 4) as usize];
81    
82    // Generate 4 frames of a spinning indicator.
83    // Each frame rotates the "bright spot" by 90° (PI/2) around the circle.
84    for frame in 0..4 {
85        let base_x = frame * SPRITE_SIZE;
86        let center = SPRITE_SIZE as f32 / 2.0;
87        let radius = SPRITE_SIZE as f32 / 2.0 - 4.0; // 4px inset from sprite edge
88        
89        for y in 0..SPRITE_SIZE {
90            for x in 0..SPRITE_SIZE {
91                let px = (base_x + x) as usize;
92                let py = y as usize;
93                let idx = (py * width as usize + px) * 4;
94                
95                let dx = x as f32 - center;
96                let dy = y as f32 - center;
97                let dist = (dx * dx + dy * dy).sqrt();
98                let angle = dy.atan2(dx);
99                
100                // Draw a 3px-wide circle outline (anti-aliased ring)
101                if (dist - radius).abs() < 3.0 {
102                    // Rotate the "bright spot" origin by 90° per frame
103                    let segment_angle = std::f32::consts::PI / 2.0 * frame as f32;
104                    // Compute angle relative to this frame's origin, then wrap to [0, 2π]
105                    let mut rel_angle = angle - segment_angle;
106                    while rel_angle < 0.0 {
107                        rel_angle += std::f32::consts::PI * 2.0;
108                    }
109                    while rel_angle > std::f32::consts::PI * 2.0 {
110                        rel_angle -= std::f32::consts::PI * 2.0;
111                    }
112
113                    // Brightness fades from 1.0 (at the origin) to 0.0 going around the circle.
114                    // This creates a "comet tail" gradient effect.
115                    let brightness = 1.0 - (rel_angle / (std::f32::consts::PI * 2.0));
116                    let r = (100.0 + 155.0 * brightness) as u8; // 100..255
117                    let g = (150.0 + 105.0 * brightness) as u8; // 150..255
118                    let b = 255; // always full blue
119                    
120                    pixels[idx] = r;
121                    pixels[idx + 1] = g;
122                    pixels[idx + 2] = b;
123                    pixels[idx + 3] = 255;
124                } else if dist < radius - 3.0 {
125                    // Inner fill: alpha fades from 0.3 at 10px inside the ring to 0.0 at the ring edge.
126                    // This gives a subtle glow effect inside the spinner.
127                    let alpha = ((radius - 3.0 - dist) / 10.0).clamp(0.0, 0.3);
128                    pixels[idx] = 100;
129                    pixels[idx + 1] = 150;
130                    pixels[idx + 2] = 200;
131                    pixels[idx + 3] = (alpha * 255.0) as u8;
132                }
133            }
134        }
135    }
136    
137    (pixels, width, height)
138}
139
140struct App {
141    _context: Arc<GraphicsContext>,
142    windows: HashMap<WindowId, RenderableWindow>,
143    pipeline: wgpu::RenderPipeline,
144    bind_group: wgpu::BindGroup,
145    vertex_buffer: wgpu::Buffer,
146    uniform_buffer: wgpu::Buffer,
147    sprite_sheet: SpriteSheet,
148    animation: SpriteAnimation,
149    last_update: Instant,
150}
151
152fn main() {
153    logging::init();
154
155    run_app(|ctx| {
156        let graphics_ctx = GraphicsContext::new_owned_sync().expect("Failed to create graphics context");
157        let mut windows = HashMap::new();
158
159        let scale = Window::platform_dpi() as f32;
160        let window = ctx
161            .create_window(WindowDescriptor {
162                title: "Sprite Sheet Animation Example".to_string(),
163                size: Some(WinitPhysicalSize::new(400.0 * scale, 400.0 * scale)),
164                ..Default::default()
165            })
166            .expect("Failed to create window");
167
168        let renderable_window = RenderableWindow::new_with_descriptor(
169            window,
170            graphics_ctx.clone(),
171            WindowContextDescriptor {
172                format: Some(wgpu::TextureFormat::Bgra8UnormSrgb),
173                ..Default::default()
174            },
175        ).expect("Failed to create renderable window");
176
177        let window_id = renderable_window.id();
178        windows.insert(window_id, renderable_window);
179
180        // Generate sprite sheet
181        let (sprite_data, tex_width, tex_height) = generate_sprite_sheet_data();
182        let sprite_sheet = SpriteSheet::from_data(
183            &graphics_ctx,
184            &sprite_data,
185            tex_width,
186            tex_height,
187            SpriteSheetDescriptor {
188                sprite_width: 64,
189                sprite_height: 64,
190                columns: 4,
191                rows: 1,
192                ..Default::default()
193            },
194        );
195
196        // Create animation (4 frames at 8 fps)
197        let animation = SpriteAnimation::new(4, 8.0);
198
199        // Create shader module
200        let shader = graphics_ctx.device().create_shader_module(wgpu::ShaderModuleDescriptor {
201            label: Some("Sprite Shader"),
202            source: wgpu::ShaderSource::Wgsl(SHADER.into()),
203        });
204
205        // Create bind group layout
206        let bind_group_layout = graphics_ctx.device().create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
207            label: Some("Sprite Bind Group Layout"),
208            entries: &[
209                wgpu::BindGroupLayoutEntry {
210                    binding: 0,
211                    visibility: wgpu::ShaderStages::VERTEX,
212                    ty: wgpu::BindingType::Buffer {
213                        ty: wgpu::BufferBindingType::Uniform,
214                        has_dynamic_offset: false,
215                        min_binding_size: None,
216                    },
217                    count: None,
218                },
219                wgpu::BindGroupLayoutEntry {
220                    binding: 1,
221                    visibility: wgpu::ShaderStages::FRAGMENT,
222                    ty: wgpu::BindingType::Texture {
223                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
224                        view_dimension: wgpu::TextureViewDimension::D2,
225                        multisampled: false,
226                    },
227                    count: None,
228                },
229                wgpu::BindGroupLayoutEntry {
230                    binding: 2,
231                    visibility: wgpu::ShaderStages::FRAGMENT,
232                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
233                    count: None,
234                },
235            ],
236        });
237
238        // Create pipeline layout
239        let pipeline_layout = graphics_ctx.device().create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
240            label: Some("Sprite Pipeline Layout"),
241            bind_group_layouts: &[&bind_group_layout],
242            push_constant_ranges: &[],
243        });
244
245        // Create render pipeline
246        let pipeline = graphics_ctx.device().create_render_pipeline(&wgpu::RenderPipelineDescriptor {
247            label: Some("Sprite Pipeline"),
248            layout: Some(&pipeline_layout),
249            vertex: wgpu::VertexState {
250                module: &shader,
251                entry_point: Some("vs_main"),
252                buffers: &[wgpu::VertexBufferLayout {
253                    array_stride: std::mem::size_of::<Vertex>() as u64,
254                    step_mode: wgpu::VertexStepMode::Vertex,
255                    attributes: &[
256                        wgpu::VertexAttribute {
257                            offset: 0,
258                            shader_location: 0,
259                            format: wgpu::VertexFormat::Float32x2,
260                        },
261                        wgpu::VertexAttribute {
262                            offset: 8,
263                            shader_location: 1,
264                            format: wgpu::VertexFormat::Float32x2,
265                        },
266                    ],
267                }],
268                compilation_options: wgpu::PipelineCompilationOptions::default(),
269            },
270            fragment: Some(wgpu::FragmentState {
271                module: &shader,
272                entry_point: Some("fs_main"),
273                targets: &[Some(wgpu::ColorTargetState {
274                    format: wgpu::TextureFormat::Bgra8UnormSrgb,
275                    blend: Some(wgpu::BlendState::ALPHA_BLENDING),
276                    write_mask: wgpu::ColorWrites::ALL,
277                })],
278                compilation_options: wgpu::PipelineCompilationOptions::default(),
279            }),
280            primitive: wgpu::PrimitiveState {
281                topology: wgpu::PrimitiveTopology::TriangleList,
282                ..Default::default()
283            },
284            depth_stencil: None,
285            multisample: wgpu::MultisampleState::default(),
286            multiview: None,
287            cache: None,
288        });
289
290        // Create uniform buffer
291        let uniforms = Uniforms {
292            mvp: [
293                [1.0, 0.0, 0.0, 0.0],
294                [0.0, 1.0, 0.0, 0.0],
295                [0.0, 0.0, 1.0, 0.0],
296                [0.0, 0.0, 0.0, 1.0],
297            ],
298        };
299        let uniform_buffer = graphics_ctx.device().create_buffer_init(&wgpu::util::BufferInitDescriptor {
300            label: Some("Uniform Buffer"),
301            contents: bytemuck::cast_slice(&[uniforms]),
302            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
303        });
304
305        // Create sampler
306        let sampler = graphics_ctx.device().create_sampler(&wgpu::SamplerDescriptor {
307            label: Some("Sprite Sampler"),
308            mag_filter: wgpu::FilterMode::Linear,
309            min_filter: wgpu::FilterMode::Linear,
310            ..Default::default()
311        });
312
313        // Create bind group
314        let bind_group = graphics_ctx.device().create_bind_group(&wgpu::BindGroupDescriptor {
315            label: Some("Sprite Bind Group"),
316            layout: &bind_group_layout,
317            entries: &[
318                wgpu::BindGroupEntry {
319                    binding: 0,
320                    resource: uniform_buffer.as_entire_binding(),
321                },
322                wgpu::BindGroupEntry {
323                    binding: 1,
324                    resource: wgpu::BindingResource::TextureView(sprite_sheet.view()),
325                },
326                wgpu::BindGroupEntry {
327                    binding: 2,
328                    resource: wgpu::BindingResource::Sampler(&sampler),
329                },
330            ],
331        });
332
333        // Initial vertex buffer (will be updated each frame with new UVs)
334        let vertices = create_quad_vertices(0.0, 0.0, 1.0, 1.0);
335        let vertex_buffer = graphics_ctx.device().create_buffer_init(&wgpu::util::BufferInitDescriptor {
336            label: Some("Vertex Buffer"),
337            contents: bytemuck::cast_slice(&vertices),
338            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
339        });
340
341        Box::new(App {
342            _context: graphics_ctx,
343            windows,
344            pipeline,
345            bind_group,
346            vertex_buffer,
347            uniform_buffer,
348            sprite_sheet,
349            animation,
350            last_update: Instant::now(),
351        })
352    });
353}
354
355fn create_quad_vertices(u_min: f32, v_min: f32, u_max: f32, v_max: f32) -> [Vertex; 6] {
356    [
357        Vertex { position: [-0.5, -0.5], uv: [u_min, v_max] },
358        Vertex { position: [0.5, -0.5], uv: [u_max, v_max] },
359        Vertex { position: [0.5, 0.5], uv: [u_max, v_min] },
360        Vertex { position: [-0.5, -0.5], uv: [u_min, v_max] },
361        Vertex { position: [0.5, 0.5], uv: [u_max, v_min] },
362        Vertex { position: [-0.5, 0.5], uv: [u_min, v_min] },
363    ]
364}
365
366impl astrelis_winit::app::App for App {
367    fn update(&mut self, _ctx: &mut astrelis_winit::app::AppCtx, _time: &astrelis_winit::FrameTime) {
368        let now = Instant::now();
369        let dt = now.duration_since(self.last_update).as_secs_f32();
370        self.last_update = now;
371
372        // Update animation
373        if self.animation.update(dt) {
374            // Frame changed - update vertex buffer with new UVs
375            let frame = self.animation.current_frame();
376            let uv = self.sprite_sheet.sprite_uv(frame);
377            let vertices = create_quad_vertices(uv.u_min, uv.v_min, uv.u_max, uv.v_max);
378            
379            // Get context from first window
380            if let Some(window) = self.windows.values().next() {
381                window.context().graphics_context().queue().write_buffer(
382                    &self.vertex_buffer,
383                    0,
384                    bytemuck::cast_slice(&vertices),
385                );
386            }
387        }
388    }
389
390    fn render(
391        &mut self,
392        _ctx: &mut astrelis_winit::app::AppCtx,
393        window_id: WindowId,
394        events: &mut astrelis_winit::event::EventBatch,
395    ) {
396        let Some(window) = self.windows.get_mut(&window_id) else {
397            return;
398        };
399
400        // Handle resize
401        events.dispatch(|event| {
402            if let astrelis_winit::event::Event::WindowResized(size) = event {
403                window.resized(*size);
404                astrelis_winit::event::HandleStatus::consumed()
405            } else {
406                astrelis_winit::event::HandleStatus::ignored()
407            }
408        });
409
410        let mut frame = window.begin_drawing();
411
412        // Render with automatic scoping (no manual {} block needed)
413        frame.clear_and_render(
414            RenderTarget::Surface,
415            Color::rgb(0.1, 0.1, 0.15),
416            |pass| {
417                let pass = pass.wgpu_pass();
418                pass.set_pipeline(&self.pipeline);
419                pass.set_bind_group(0, &self.bind_group, &[]);
420                pass.set_vertex_buffer(0, self.vertex_buffer.slice(..));
421                pass.draw(0..6, 0..1);
422            },
423        );
424
425        frame.finish();
426    }
427}