Skip to main content

engine/
sprite.rs

1/**--------------------------------------------------------------------------------
2*!  2D sprite rendering system with sprite sheet support.
3*?  Supports:
4*?  - Instanced rendering for performance
5*?  - Sprite sheets with UV coordinates
6*?  - Color tinting
7*?  - Multiple textures
8*--------------------------------------------------------------------------------**/
9use bytemuck::{Pod, Zeroable};
10use glam::Vec2;
11use wgpu::util::DeviceExt;
12
13use crate::camera::Camera;
14use crate::texture::Texture;
15
16const MAX_SPRITES: usize = 4096;
17
18//? GPU blend mode for sprite rendering.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
20pub enum BlendMode {
21    #[default]
22    Alpha,
23    Additive,
24}
25
26//? A rectangle defining a region (screen coordinates or sprite sheet).
27#[derive(Debug, Clone, Copy, PartialEq)]
28pub struct Rect {
29    pub x: f32,
30    pub y: f32,
31    pub w: f32,
32    pub h: f32,
33}
34
35impl Rect {
36    pub fn new(x: f32, y: f32, w: f32, h: f32) -> Self {
37        Self { x, y, w, h }
38    }
39
40    //? Create a rect from position and size vectors.
41    pub fn from_pos_size(pos: Vec2, size: Vec2) -> Self {
42        Self {
43            x: pos.x,
44            y: pos.y,
45            w: size.x,
46            h: size.y,
47        }
48    }
49}
50
51//? A sprite instance (position, size, color, UV).
52#[repr(C)]
53#[derive(Copy, Clone, Debug, Pod, Zeroable)]
54pub struct SpriteInstance {
55    position: [f32; 2],
56    scale: [f32; 2],
57    color: [f32; 4],
58    uv_offset: [f32; 2], //* Top-left UV
59    uv_size: [f32; 2],   //* UV width/height
60}
61
62impl SpriteInstance {
63    pub fn new(
64        position: Vec2,
65        scale: Vec2,
66        color: [f32; 4],
67        uv_offset: Vec2,
68        uv_size: Vec2,
69    ) -> Self {
70        Self {
71            position: position.to_array(),
72            scale: scale.to_array(),
73            color,
74            uv_offset: uv_offset.to_array(),
75            uv_size: uv_size.to_array(),
76        }
77    }
78}
79
80//? High-level sprite data (user-facing).
81pub struct Sprite {
82    pub position: Vec2,
83    pub size: Vec2,
84    pub color: [f32; 4],
85    pub source_rect: Option<Rect>, //* Sprite sheet region (pixel coords)
86    pub flip_x: bool,
87    pub texture_id: usize, //* Which texture to use (0 = white pixel for rects)
88    pub blend_mode: BlendMode,
89}
90
91//? User-facing sprite definition with various builder methods.
92impl Sprite {
93    pub fn new(position: Vec2, size: Vec2, color: [f32; 4]) -> Self {
94        Self {
95            position,
96            size,
97            color,
98            source_rect: None,
99            flip_x: false,
100            texture_id: 0, //* Default to white pixel
101            blend_mode: BlendMode::Alpha,
102        }
103    }
104
105    pub fn with_source(mut self, rect: Rect) -> Self {
106        self.source_rect = Some(rect);
107        self
108    }
109
110    pub fn with_flip(mut self, flip_x: bool) -> Self {
111        self.flip_x = flip_x;
112        self
113    }
114
115    pub fn with_texture_id(mut self, texture_id: usize) -> Self {
116        self.texture_id = texture_id;
117        self
118    }
119
120    pub fn with_blend_mode(mut self, blend_mode: BlendMode) -> Self {
121        self.blend_mode = blend_mode;
122        self
123    }
124
125    //? Convert high-level Sprite to low-level SpriteInstance for rendering.
126    //* Calculates UV coordinates based on source_rect and texture size,
127    //* and applies horizontal flip by negating scale.
128    fn to_instance(&self, texture_width: f32, texture_height: f32) -> SpriteInstance {
129        let (uv_offset, uv_size) = if let Some(src) = self.source_rect {
130            //? Convert pixel coordinates to UV (0.0-1.0)
131            let u = src.x / texture_width;
132            let v = src.y / texture_height;
133            let uw = src.w / texture_width;
134            let vh = src.h / texture_height;
135            (Vec2::new(u, v), Vec2::new(uw, vh))
136        } else {
137            //* Use full texture
138            (Vec2::ZERO, Vec2::ONE)
139        };
140
141        //? Flip horizontally by mirroring the UV: start sampling from the RIGHT edge
142        //? of the frame and walk left (negative uv_size.x). This keeps scale always
143        //? positive and position always top-left, so callers never need to pre-shift
144        //? the anchor and thus eliminating the ghost/teleport double-offset problem.
145        let (uv_offset, uv_size) = if self.flip_x {
146            (
147                Vec2::new(uv_offset.x + uv_size.x, uv_offset.y),
148                Vec2::new(-uv_size.x, uv_size.y),
149            )
150        } else {
151            (uv_offset, uv_size)
152        };
153
154        SpriteInstance::new(self.position, self.size, self.color, uv_offset, uv_size)
155    }
156}
157
158//? Sprite rendering system.
159pub struct SpriteRenderer {
160    pipeline: wgpu::RenderPipeline,
161    additive_pipeline: wgpu::RenderPipeline,
162    camera_buffer: wgpu::Buffer,
163    camera_bind_group: wgpu::BindGroup,
164    texture_bind_group_layout: wgpu::BindGroupLayout,
165    #[allow(dead_code)]
166    default_texture: Texture,
167    default_bind_group: wgpu::BindGroup,
168    rect_instance_buffer: wgpu::Buffer,
169    rect_instance_data: Vec<SpriteInstance>,
170    sprite_instance_buffer: wgpu::Buffer,
171
172    //? Pre-uploaded batch ranges: (texture_id, start_instance..end_instance)
173    batch_ranges: Vec<(usize, std::ops::Range<u32>)>,
174    additive_batch_ranges: Vec<(usize, std::ops::Range<u32>)>,
175
176    //? Reusable per-frame texture batch map (avoids heap alloc each frame)
177    texture_batches: Vec<Vec<SpriteInstance>>,
178    additive_texture_batches: Vec<Vec<SpriteInstance>>,
179}
180
181impl SpriteRenderer {
182    pub fn new(
183        device: &wgpu::Device,
184        queue: &wgpu::Queue,
185        format: wgpu::TextureFormat,
186        camera: &Camera,
187    ) -> Self {
188        //? Create default 1x1 white pixel texture
189        let default_texture = Texture::white_pixel(device, queue);
190
191        //? Camera uniform buffer
192        let camera_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
193            label: Some("Camera Uniform Buffer"),
194            contents: bytemuck::cast_slice(&[*camera.uniform()]),
195            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
196        });
197
198        //? Camera bind group layout
199        let camera_bind_group_layout =
200            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
201                label: Some("Camera Bind Group Layout"),
202                entries: &[wgpu::BindGroupLayoutEntry {
203                    binding: 0,
204                    visibility: wgpu::ShaderStages::VERTEX,
205                    ty: wgpu::BindingType::Buffer {
206                        ty: wgpu::BufferBindingType::Uniform,
207                        has_dynamic_offset: false,
208                        min_binding_size: None,
209                    },
210                    count: None,
211                }],
212            });
213
214        //? Camera bind group
215        let camera_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
216            label: Some("Camera Bind Group"),
217            layout: &camera_bind_group_layout,
218            entries: &[wgpu::BindGroupEntry {
219                binding: 0,
220                resource: camera_buffer.as_entire_binding(),
221            }],
222        });
223
224        //? Texture bind group layout (for both default and custom textures)
225        let texture_bind_group_layout =
226            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
227                label: Some("Sprite Texture Bind Group Layout"),
228                entries: &[
229                    wgpu::BindGroupLayoutEntry {
230                        binding: 0,
231                        visibility: wgpu::ShaderStages::FRAGMENT,
232                        ty: wgpu::BindingType::Texture {
233                            sample_type: wgpu::TextureSampleType::Float { filterable: true },
234                            view_dimension: wgpu::TextureViewDimension::D2,
235                            multisampled: false,
236                        },
237                        count: None,
238                    },
239                    wgpu::BindGroupLayoutEntry {
240                        binding: 1,
241                        visibility: wgpu::ShaderStages::FRAGMENT,
242                        ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
243                        count: None,
244                    },
245                ],
246            });
247
248        //? Default bind group for the white pixel texture (used for rects)
249        let default_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
250            label: Some("Default Sprite Texture Bind Group"),
251            layout: &texture_bind_group_layout,
252            entries: &[
253                wgpu::BindGroupEntry {
254                    binding: 0,
255                    resource: wgpu::BindingResource::TextureView(&default_texture.view),
256                },
257                wgpu::BindGroupEntry {
258                    binding: 1,
259                    resource: wgpu::BindingResource::Sampler(&default_texture.sampler),
260                },
261            ],
262        });
263
264        //? Instance buffers
265        let rect_instance_buffer = device.create_buffer(&wgpu::BufferDescriptor {
266            label: Some("Rect Instance Buffer"),
267            size: (MAX_SPRITES * std::mem::size_of::<SpriteInstance>()) as u64,
268            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
269            mapped_at_creation: false,
270        });
271
272        let sprite_instance_buffer = device.create_buffer(&wgpu::BufferDescriptor {
273            label: Some("Sprite Instance Buffer"),
274            size: (MAX_SPRITES * std::mem::size_of::<SpriteInstance>()) as u64,
275            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
276            mapped_at_creation: false,
277        });
278
279        //? Pipeline setup
280        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
281            label: Some("Sprite Shader"),
282            source: wgpu::ShaderSource::Wgsl(
283                include_str!("../assets/shaders/shader_sprite.wgsl").into(),
284            ),
285        });
286
287        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
288            label: Some("Sprite Pipeline Layout"),
289            bind_group_layouts: &[&camera_bind_group_layout, &texture_bind_group_layout],
290            push_constant_ranges: &[],
291        });
292
293        //? Create render pipeline with instancing and texture support
294        let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
295            label: Some("Sprite Pipeline"),
296            layout: Some(&pipeline_layout),
297            vertex: wgpu::VertexState {
298                module: &shader,
299                entry_point: Some("vs_main"),
300                buffers: &[wgpu::VertexBufferLayout {
301                    array_stride: std::mem::size_of::<SpriteInstance>() as u64,
302                    step_mode: wgpu::VertexStepMode::Instance,
303                    //? The first 8 bytes of the SpriteInstance struct are interpreted as a vec2 for position,
304                    //? the next 8 bytes as a vec2 for scale, the next 16 bytes as a vec4 for color, and so on.
305                    //? The shader will read these attributes from the vertex buffer when rendering each instance.
306                    attributes: &[
307                        //* Position
308                        wgpu::VertexAttribute {
309                            format: wgpu::VertexFormat::Float32x2,
310                            offset: 0,
311                            shader_location: 0,
312                        },
313                        //* Scale
314                        wgpu::VertexAttribute {
315                            format: wgpu::VertexFormat::Float32x2,
316                            offset: 8,
317                            shader_location: 1,
318                        },
319                        //* Color
320                        wgpu::VertexAttribute {
321                            format: wgpu::VertexFormat::Float32x4,
322                            offset: 16,
323                            shader_location: 2,
324                        },
325                        //* UV Offset
326                        wgpu::VertexAttribute {
327                            format: wgpu::VertexFormat::Float32x2,
328                            offset: 32,
329                            shader_location: 3,
330                        },
331                        //* UV Size
332                        wgpu::VertexAttribute {
333                            format: wgpu::VertexFormat::Float32x2,
334                            offset: 40,
335                            shader_location: 4,
336                        },
337                    ],
338                }],
339                compilation_options: Default::default(),
340            },
341            //? Fragment state with alpha blending for transparency
342            fragment: Some(wgpu::FragmentState {
343                module: &shader,
344                entry_point: Some("fs_main"),
345                targets: &[Some(wgpu::ColorTargetState {
346                    format,
347                    blend: Some(wgpu::BlendState::ALPHA_BLENDING),
348                    write_mask: wgpu::ColorWrites::ALL,
349                })],
350                compilation_options: Default::default(),
351            }),
352            //? Primitive state for triangle list (two triangles per sprite)
353            primitive: wgpu::PrimitiveState {
354                topology: wgpu::PrimitiveTopology::TriangleList,
355                ..Default::default()
356            },
357            depth_stencil: None,
358            multisample: wgpu::MultisampleState::default(),
359            multiview: None,
360            cache: None,
361        });
362
363        let vertex_buffer_layout = wgpu::VertexBufferLayout {
364            array_stride: std::mem::size_of::<SpriteInstance>() as u64,
365            step_mode: wgpu::VertexStepMode::Instance,
366            attributes: &[
367                wgpu::VertexAttribute {
368                    format: wgpu::VertexFormat::Float32x2,
369                    offset: 0,
370                    shader_location: 0,
371                },
372                wgpu::VertexAttribute {
373                    format: wgpu::VertexFormat::Float32x2,
374                    offset: 8,
375                    shader_location: 1,
376                },
377                wgpu::VertexAttribute {
378                    format: wgpu::VertexFormat::Float32x4,
379                    offset: 16,
380                    shader_location: 2,
381                },
382                wgpu::VertexAttribute {
383                    format: wgpu::VertexFormat::Float32x2,
384                    offset: 32,
385                    shader_location: 3,
386                },
387                wgpu::VertexAttribute {
388                    format: wgpu::VertexFormat::Float32x2,
389                    offset: 40,
390                    shader_location: 4,
391                },
392            ],
393        };
394
395        //? Additive blend pipeline: src color adds to dest (glow effects)
396        let additive_blend = wgpu::BlendState {
397            color: wgpu::BlendComponent {
398                src_factor: wgpu::BlendFactor::SrcAlpha,
399                dst_factor: wgpu::BlendFactor::One,
400                operation: wgpu::BlendOperation::Add,
401            },
402            alpha: wgpu::BlendComponent {
403                src_factor: wgpu::BlendFactor::One,
404                dst_factor: wgpu::BlendFactor::One,
405                operation: wgpu::BlendOperation::Add,
406            },
407        };
408
409        let additive_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
410            label: Some("Sprite Additive Pipeline"),
411            layout: Some(&pipeline_layout),
412            vertex: wgpu::VertexState {
413                module: &shader,
414                entry_point: Some("vs_main"),
415                buffers: &[vertex_buffer_layout],
416                compilation_options: Default::default(),
417            },
418            fragment: Some(wgpu::FragmentState {
419                module: &shader,
420                entry_point: Some("fs_main"),
421                targets: &[Some(wgpu::ColorTargetState {
422                    format,
423                    blend: Some(additive_blend),
424                    write_mask: wgpu::ColorWrites::ALL,
425                })],
426                compilation_options: Default::default(),
427            }),
428            primitive: wgpu::PrimitiveState {
429                topology: wgpu::PrimitiveTopology::TriangleList,
430                ..Default::default()
431            },
432            depth_stencil: None,
433            multisample: wgpu::MultisampleState::default(),
434            multiview: None,
435            cache: None,
436        });
437
438        //? Return the initialized SpriteRenderer
439        Self {
440            pipeline,
441            additive_pipeline,
442            camera_buffer,
443            camera_bind_group,
444            texture_bind_group_layout,
445            default_texture,
446            default_bind_group,
447            rect_instance_buffer,
448            rect_instance_data: Vec::with_capacity(MAX_SPRITES),
449            sprite_instance_buffer,
450            batch_ranges: Vec::new(),
451            additive_batch_ranges: Vec::new(),
452            texture_batches: Vec::new(),
453            additive_texture_batches: Vec::new(),
454        }
455    }
456
457    //? Create a bind group for a custom texture.
458    pub fn create_texture_bind_group(
459        &self,
460        device: &wgpu::Device,
461        texture: &Texture,
462    ) -> wgpu::BindGroup {
463        device.create_bind_group(&wgpu::BindGroupDescriptor {
464            label: Some("Custom Sprite Texture Bind Group"),
465            layout: &self.texture_bind_group_layout,
466            //? Bind the provided texture's view and sampler to the bind group for use in the shader.
467            //? This allows the shader to sample from this texture when rendering sprites that use it.
468            entries: &[
469                wgpu::BindGroupEntry {
470                    binding: 0,
471                    resource: wgpu::BindingResource::TextureView(&texture.view),
472                },
473                wgpu::BindGroupEntry {
474                    binding: 1,
475                    resource: wgpu::BindingResource::Sampler(&texture.sampler),
476                },
477            ],
478        })
479    }
480
481    //? Update camera uniform (call after camera.resize()).
482    pub fn update_camera(&self, queue: &wgpu::Queue, camera: &Camera) {
483        queue.write_buffer(
484            &self.camera_buffer,
485            0,
486            bytemuck::cast_slice(&[*camera.uniform()]),
487        );
488    }
489
490    //? Prepare sprites for rendering: batch by texture and blend mode, upload once.
491    pub fn prepare(
492        &mut self,
493        queue: &wgpu::Queue,
494        sprites: &[Sprite],
495        texture_sizes: &[(f32, f32)],
496    ) {
497        self.rect_instance_data.clear();
498        self.batch_ranges.clear();
499        self.additive_batch_ranges.clear();
500
501        //? Clear and reuse per-texture batch vectors (avoids HashMap alloc each frame)
502        for batch in &mut self.texture_batches {
503            batch.clear();
504        }
505        for batch in &mut self.additive_texture_batches {
506            batch.clear();
507        }
508
509        //? Convert high-level Sprite definitions to low-level SpriteInstance data,
510        //? partitioned by blend mode and then by texture ID.
511        if sprites.len() > MAX_SPRITES {
512            log::warn!(
513                "Sprite overflow: {} submitted, capped at {}",
514                sprites.len(),
515                MAX_SPRITES
516            );
517        }
518        for sprite in sprites.iter().take(MAX_SPRITES) {
519            if sprite.source_rect.is_some() {
520                let (tex_width, tex_height) = texture_sizes
521                    .get(sprite.texture_id)
522                    .copied()
523                    .unwrap_or((1.0, 1.0));
524
525                let instance = sprite.to_instance(tex_width, tex_height);
526
527                let batches = match sprite.blend_mode {
528                    BlendMode::Alpha => &mut self.texture_batches,
529                    BlendMode::Additive => &mut self.additive_texture_batches,
530                };
531
532                if sprite.texture_id >= batches.len() {
533                    batches.resize_with(sprite.texture_id + 1, Vec::new);
534                }
535                batches[sprite.texture_id].push(instance);
536            } else {
537                self.rect_instance_data.push(sprite.to_instance(1.0, 1.0));
538            }
539        }
540
541        //? Upload rect instances
542        if !self.rect_instance_data.is_empty() {
543            queue.write_buffer(
544                &self.rect_instance_buffer,
545                0,
546                bytemuck::cast_slice(&self.rect_instance_data),
547            );
548        }
549
550        //? Concatenate alpha batches, then additive batches, into one contiguous buffer
551        let mut all_instances: Vec<SpriteInstance> = Vec::new();
552
553        for (tex_id, instances) in self.texture_batches.iter().enumerate() {
554            if instances.is_empty() {
555                continue;
556            }
557            let start = all_instances.len() as u32;
558            all_instances.extend_from_slice(instances);
559            self.batch_ranges
560                .push((tex_id, start..all_instances.len() as u32));
561        }
562
563        for (tex_id, instances) in self.additive_texture_batches.iter().enumerate() {
564            if instances.is_empty() {
565                continue;
566            }
567            let start = all_instances.len() as u32;
568            all_instances.extend_from_slice(instances);
569            self.additive_batch_ranges
570                .push((tex_id, start..all_instances.len() as u32));
571        }
572
573        if !all_instances.is_empty() {
574            queue.write_buffer(
575                &self.sprite_instance_buffer,
576                0,
577                bytemuck::cast_slice(&all_instances),
578            );
579        }
580    }
581
582    //? Render all prepared sprites with multiple texture bind groups.
583    //? Alpha-blended sprites first, then additive-blended sprites.
584    //? All buffer uploads happen in prepare(); this only binds and draws.
585    pub fn render_multi<'rpass>(
586        &'rpass self,
587        render_pass: &mut wgpu::RenderPass<'rpass>,
588        bind_groups: &'rpass [wgpu::BindGroup],
589    ) {
590        render_pass.set_bind_group(0, &self.camera_bind_group, &[]);
591
592        //? Draw rects with the 1x1 white pixel texture (always alpha blend)
593        if !self.rect_instance_data.is_empty() {
594            render_pass.set_pipeline(&self.pipeline);
595            render_pass.set_bind_group(1, &self.default_bind_group, &[]);
596            render_pass.set_vertex_buffer(0, self.rect_instance_buffer.slice(..));
597            render_pass.draw(0..6, 0..self.rect_instance_data.len() as u32);
598        }
599
600        //? Draw alpha-blended texture batches
601        let has_alpha = !self.batch_ranges.is_empty();
602        let has_additive = !self.additive_batch_ranges.is_empty();
603
604        if has_alpha || has_additive {
605            render_pass.set_vertex_buffer(0, self.sprite_instance_buffer.slice(..));
606        }
607
608        if has_alpha {
609            render_pass.set_pipeline(&self.pipeline);
610
611            for &(texture_id, ref range) in &self.batch_ranges {
612                let bind_group = bind_groups
613                    .get(texture_id)
614                    .unwrap_or(&self.default_bind_group);
615
616                render_pass.set_bind_group(1, bind_group, &[]);
617                render_pass.draw(0..6, range.clone());
618            }
619        }
620
621        //? Draw additive-blended texture batches (glow effects)
622        if has_additive {
623            render_pass.set_pipeline(&self.additive_pipeline);
624
625            for &(texture_id, ref range) in &self.additive_batch_ranges {
626                let bind_group = bind_groups
627                    .get(texture_id)
628                    .unwrap_or(&self.default_bind_group);
629
630                render_pass.set_bind_group(1, bind_group, &[]);
631                render_pass.draw(0..6, range.clone());
632            }
633        }
634    }
635}