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, RenderWindow, RenderWindowBuilder, SpriteAnimation, SpriteSheet,
13    SpriteSheetDescriptor,
14};
15use astrelis_winit::{
16    WindowId,
17    app::run_app,
18    window::{Window, WindowDescriptor, WinitPhysicalSize},
19};
20use std::collections::HashMap;
21use std::sync::Arc;
22use std::time::Instant;
23use wgpu::util::DeviceExt;
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, RenderWindow>,
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 =
157            GraphicsContext::new_owned_sync().expect("Failed to create graphics context");
158        let mut windows = HashMap::new();
159
160        let scale = Window::platform_dpi() as f32;
161        let window = ctx
162            .create_window(WindowDescriptor {
163                title: "Sprite Sheet Animation Example".to_string(),
164                size: Some(WinitPhysicalSize::new(400.0 * scale, 400.0 * scale)),
165                ..Default::default()
166            })
167            .expect("Failed to create window");
168
169        let renderable_window = RenderWindowBuilder::new()
170            .color_format(wgpu::TextureFormat::Bgra8UnormSrgb)
171            .with_depth_default()
172            .build(window, graphics_ctx.clone())
173            .expect("Failed to create render window");
174
175        let window_id = renderable_window.id();
176        windows.insert(window_id, renderable_window);
177
178        // Generate sprite sheet
179        let (sprite_data, tex_width, tex_height) = generate_sprite_sheet_data();
180        let sprite_sheet = SpriteSheet::from_data(
181            &graphics_ctx,
182            &sprite_data,
183            tex_width,
184            tex_height,
185            SpriteSheetDescriptor {
186                sprite_width: 64,
187                sprite_height: 64,
188                columns: 4,
189                rows: 1,
190                ..Default::default()
191            },
192        );
193
194        // Create animation (4 frames at 8 fps)
195        let animation = SpriteAnimation::new(4, 8.0);
196
197        // Create shader module
198        let shader = graphics_ctx
199            .device()
200            .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 =
207            graphics_ctx
208                .device()
209                .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
210                    label: Some("Sprite Bind Group Layout"),
211                    entries: &[
212                        wgpu::BindGroupLayoutEntry {
213                            binding: 0,
214                            visibility: wgpu::ShaderStages::VERTEX,
215                            ty: wgpu::BindingType::Buffer {
216                                ty: wgpu::BufferBindingType::Uniform,
217                                has_dynamic_offset: false,
218                                min_binding_size: None,
219                            },
220                            count: None,
221                        },
222                        wgpu::BindGroupLayoutEntry {
223                            binding: 1,
224                            visibility: wgpu::ShaderStages::FRAGMENT,
225                            ty: wgpu::BindingType::Texture {
226                                sample_type: wgpu::TextureSampleType::Float { filterable: true },
227                                view_dimension: wgpu::TextureViewDimension::D2,
228                                multisampled: false,
229                            },
230                            count: None,
231                        },
232                        wgpu::BindGroupLayoutEntry {
233                            binding: 2,
234                            visibility: wgpu::ShaderStages::FRAGMENT,
235                            ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
236                            count: None,
237                        },
238                    ],
239                });
240
241        // Create pipeline layout
242        let pipeline_layout =
243            graphics_ctx
244                .device()
245                .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
246                    label: Some("Sprite Pipeline Layout"),
247                    bind_group_layouts: &[&bind_group_layout],
248                    push_constant_ranges: &[],
249                });
250
251        // Create render pipeline
252        let pipeline =
253            graphics_ctx
254                .device()
255                .create_render_pipeline(&wgpu::RenderPipelineDescriptor {
256                    label: Some("Sprite Pipeline"),
257                    layout: Some(&pipeline_layout),
258                    vertex: wgpu::VertexState {
259                        module: &shader,
260                        entry_point: Some("vs_main"),
261                        buffers: &[wgpu::VertexBufferLayout {
262                            array_stride: std::mem::size_of::<Vertex>() as u64,
263                            step_mode: wgpu::VertexStepMode::Vertex,
264                            attributes: &[
265                                wgpu::VertexAttribute {
266                                    offset: 0,
267                                    shader_location: 0,
268                                    format: wgpu::VertexFormat::Float32x2,
269                                },
270                                wgpu::VertexAttribute {
271                                    offset: 8,
272                                    shader_location: 1,
273                                    format: wgpu::VertexFormat::Float32x2,
274                                },
275                            ],
276                        }],
277                        compilation_options: wgpu::PipelineCompilationOptions::default(),
278                    },
279                    fragment: Some(wgpu::FragmentState {
280                        module: &shader,
281                        entry_point: Some("fs_main"),
282                        targets: &[Some(wgpu::ColorTargetState {
283                            format: wgpu::TextureFormat::Bgra8UnormSrgb,
284                            blend: Some(wgpu::BlendState::ALPHA_BLENDING),
285                            write_mask: wgpu::ColorWrites::ALL,
286                        })],
287                        compilation_options: wgpu::PipelineCompilationOptions::default(),
288                    }),
289                    primitive: wgpu::PrimitiveState {
290                        topology: wgpu::PrimitiveTopology::TriangleList,
291                        ..Default::default()
292                    },
293                    depth_stencil: None,
294                    multisample: wgpu::MultisampleState::default(),
295                    multiview: None,
296                    cache: None,
297                });
298
299        // Create uniform buffer
300        let uniforms = Uniforms {
301            mvp: [
302                [1.0, 0.0, 0.0, 0.0],
303                [0.0, 1.0, 0.0, 0.0],
304                [0.0, 0.0, 1.0, 0.0],
305                [0.0, 0.0, 0.0, 1.0],
306            ],
307        };
308        let uniform_buffer =
309            graphics_ctx
310                .device()
311                .create_buffer_init(&wgpu::util::BufferInitDescriptor {
312                    label: Some("Uniform Buffer"),
313                    contents: bytemuck::cast_slice(&[uniforms]),
314                    usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
315                });
316
317        // Create sampler
318        let sampler = graphics_ctx
319            .device()
320            .create_sampler(&wgpu::SamplerDescriptor {
321                label: Some("Sprite Sampler"),
322                mag_filter: wgpu::FilterMode::Linear,
323                min_filter: wgpu::FilterMode::Linear,
324                ..Default::default()
325            });
326
327        // Create bind group
328        let bind_group = graphics_ctx
329            .device()
330            .create_bind_group(&wgpu::BindGroupDescriptor {
331                label: Some("Sprite Bind Group"),
332                layout: &bind_group_layout,
333                entries: &[
334                    wgpu::BindGroupEntry {
335                        binding: 0,
336                        resource: uniform_buffer.as_entire_binding(),
337                    },
338                    wgpu::BindGroupEntry {
339                        binding: 1,
340                        resource: wgpu::BindingResource::TextureView(sprite_sheet.view()),
341                    },
342                    wgpu::BindGroupEntry {
343                        binding: 2,
344                        resource: wgpu::BindingResource::Sampler(&sampler),
345                    },
346                ],
347            });
348
349        // Initial vertex buffer (will be updated each frame with new UVs)
350        let vertices = create_quad_vertices(0.0, 0.0, 1.0, 1.0);
351        let vertex_buffer =
352            graphics_ctx
353                .device()
354                .create_buffer_init(&wgpu::util::BufferInitDescriptor {
355                    label: Some("Vertex Buffer"),
356                    contents: bytemuck::cast_slice(&vertices),
357                    usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
358                });
359
360        Box::new(App {
361            _context: graphics_ctx,
362            windows,
363            pipeline,
364            bind_group,
365            vertex_buffer,
366            _uniform_buffer: uniform_buffer,
367            sprite_sheet,
368            animation,
369            last_update: Instant::now(),
370        })
371    });
372}
373
374fn create_quad_vertices(u_min: f32, v_min: f32, u_max: f32, v_max: f32) -> [Vertex; 6] {
375    [
376        Vertex {
377            position: [-0.5, -0.5],
378            uv: [u_min, v_max],
379        },
380        Vertex {
381            position: [0.5, -0.5],
382            uv: [u_max, v_max],
383        },
384        Vertex {
385            position: [0.5, 0.5],
386            uv: [u_max, v_min],
387        },
388        Vertex {
389            position: [-0.5, -0.5],
390            uv: [u_min, v_max],
391        },
392        Vertex {
393            position: [0.5, 0.5],
394            uv: [u_max, v_min],
395        },
396        Vertex {
397            position: [-0.5, 0.5],
398            uv: [u_min, v_min],
399        },
400    ]
401}
402
403impl astrelis_winit::app::App for App {
404    fn update(
405        &mut self,
406        _ctx: &mut astrelis_winit::app::AppCtx,
407        _time: &astrelis_winit::FrameTime,
408    ) {
409        let now = Instant::now();
410        let dt = now.duration_since(self.last_update).as_secs_f32();
411        self.last_update = now;
412
413        // Update animation
414        if self.animation.update(dt) {
415            // Frame changed - update vertex buffer with new UVs
416            let frame = self.animation.current_frame();
417            let uv = self.sprite_sheet.sprite_uv(frame);
418            let vertices = create_quad_vertices(uv.u_min, uv.v_min, uv.u_max, uv.v_max);
419
420            // Get context from first window
421            if let Some(window) = self.windows.values().next() {
422                window.context().graphics_context().queue().write_buffer(
423                    &self.vertex_buffer,
424                    0,
425                    bytemuck::cast_slice(&vertices),
426                );
427            }
428        }
429    }
430
431    fn render(
432        &mut self,
433        _ctx: &mut astrelis_winit::app::AppCtx,
434        window_id: WindowId,
435        events: &mut astrelis_winit::event::EventBatch,
436    ) {
437        let Some(window) = self.windows.get_mut(&window_id) else {
438            return;
439        };
440
441        // Handle resize
442        events.dispatch(|event| {
443            if let astrelis_winit::event::Event::WindowResized(size) = event {
444                window.resized(*size);
445                astrelis_winit::event::HandleStatus::consumed()
446            } else {
447                astrelis_winit::event::HandleStatus::ignored()
448            }
449        });
450
451        let Some(frame) = window.begin_frame() else {
452            return; // Surface not available
453        };
454        {
455            let mut pass = frame
456                .render_pass()
457                .clear_color(Color::rgb(0.1, 0.1, 0.15))
458                .label("sprite_sheet_pass")
459                .build();
460            pass.set_pipeline(&self.pipeline);
461            pass.set_bind_group(0, &self.bind_group, &[]);
462            pass.set_vertex_buffer(0, self.vertex_buffer.slice(..));
463            pass.draw(0..6, 0..1);
464        }
465        // Frame auto-submits on drop
466    }
467}