Skip to main content

arcane_engine/renderer/
sprite.rs

1use bytemuck::{Pod, Zeroable};
2use wgpu::util::DeviceExt;
3
4use super::camera::Camera2D;
5use super::gpu::GpuContext;
6use super::lighting::LightingUniform;
7use super::texture::TextureStore;
8
9/// Blend mode constants. Matches TS enum order.
10pub const BLEND_ALPHA: u8 = 0;
11pub const BLEND_ADDITIVE: u8 = 1;
12pub const BLEND_MULTIPLY: u8 = 2;
13pub const BLEND_SCREEN: u8 = 3;
14
15/// A sprite draw command queued from TypeScript.
16#[derive(Debug, Clone)]
17pub struct SpriteCommand {
18    pub texture_id: u32,
19    pub x: f32,
20    pub y: f32,
21    pub w: f32,
22    pub h: f32,
23    pub layer: i32,
24    pub uv_x: f32,
25    pub uv_y: f32,
26    pub uv_w: f32,
27    pub uv_h: f32,
28    pub tint_r: f32,
29    pub tint_g: f32,
30    pub tint_b: f32,
31    pub tint_a: f32,
32    pub rotation: f32,
33    pub origin_x: f32,
34    pub origin_y: f32,
35    pub flip_x: bool,
36    pub flip_y: bool,
37    pub opacity: f32,
38    pub blend_mode: u8,
39    pub shader_id: u32,
40}
41
42/// Per-vertex data for the unit quad.
43#[repr(C)]
44#[derive(Copy, Clone, Pod, Zeroable)]
45struct QuadVertex {
46    position: [f32; 2],
47    uv: [f32; 2],
48}
49
50/// Per-instance data for each sprite.
51#[repr(C)]
52#[derive(Copy, Clone, Pod, Zeroable)]
53struct SpriteInstance {
54    world_pos: [f32; 2],
55    size: [f32; 2],
56    uv_offset: [f32; 2],
57    uv_size: [f32; 2],
58    tint: [f32; 4],
59    /// [rotation_radians, origin_x (0-1), origin_y (0-1), padding]
60    rotation_origin: [f32; 4],
61}
62
63/// Camera uniform buffer data.
64#[repr(C)]
65#[derive(Copy, Clone, Pod, Zeroable)]
66struct CameraUniform {
67    view_proj: [f32; 16],
68}
69
70// Unit quad: two triangles forming a 1x1 square at origin
71const QUAD_VERTICES: &[QuadVertex] = &[
72    QuadVertex { position: [0.0, 0.0], uv: [0.0, 0.0] }, // top-left
73    QuadVertex { position: [1.0, 0.0], uv: [1.0, 0.0] }, // top-right
74    QuadVertex { position: [1.0, 1.0], uv: [1.0, 1.0] }, // bottom-right
75    QuadVertex { position: [0.0, 1.0], uv: [0.0, 1.0] }, // bottom-left
76];
77
78const QUAD_INDICES: &[u16] = &[0, 1, 2, 0, 2, 3];
79
80/// Get the wgpu BlendState for each blend mode.
81fn blend_state_for(mode: u8) -> wgpu::BlendState {
82    use wgpu::{BlendComponent, BlendFactor, BlendOperation};
83    match mode {
84        BLEND_ALPHA => wgpu::BlendState::ALPHA_BLENDING,
85        BLEND_ADDITIVE => wgpu::BlendState {
86            color: BlendComponent {
87                src_factor: BlendFactor::SrcAlpha,
88                dst_factor: BlendFactor::One,
89                operation: BlendOperation::Add,
90            },
91            alpha: BlendComponent {
92                src_factor: BlendFactor::One,
93                dst_factor: BlendFactor::One,
94                operation: BlendOperation::Add,
95            },
96        },
97        BLEND_MULTIPLY => wgpu::BlendState {
98            color: BlendComponent {
99                src_factor: BlendFactor::Dst,
100                dst_factor: BlendFactor::OneMinusSrcAlpha,
101                operation: BlendOperation::Add,
102            },
103            alpha: BlendComponent {
104                src_factor: BlendFactor::DstAlpha,
105                dst_factor: BlendFactor::OneMinusSrcAlpha,
106                operation: BlendOperation::Add,
107            },
108        },
109        BLEND_SCREEN => wgpu::BlendState {
110            color: BlendComponent {
111                src_factor: BlendFactor::One,
112                dst_factor: BlendFactor::OneMinusSrc,
113                operation: BlendOperation::Add,
114            },
115            alpha: BlendComponent {
116                src_factor: BlendFactor::One,
117                dst_factor: BlendFactor::OneMinusSrcAlpha,
118                operation: BlendOperation::Add,
119            },
120        },
121        _ => wgpu::BlendState::ALPHA_BLENDING, // unknown → default to alpha
122    }
123}
124
125pub struct SpritePipeline {
126    /// One pipeline per blend mode: [alpha, additive, multiply, screen]
127    pipelines: [wgpu::RenderPipeline; 4],
128    vertex_buffer: wgpu::Buffer,
129    index_buffer: wgpu::Buffer,
130    camera_buffer: wgpu::Buffer,
131    camera_bind_group: wgpu::BindGroup,
132    pub texture_bind_group_layout: wgpu::BindGroupLayout,
133    lighting_buffer: wgpu::Buffer,
134    lighting_bind_group: wgpu::BindGroup,
135}
136
137impl SpritePipeline {
138    pub fn new(gpu: &GpuContext) -> Self {
139        let shader = gpu.device.create_shader_module(wgpu::ShaderModuleDescriptor {
140            label: Some("sprite_shader"),
141            source: wgpu::ShaderSource::Wgsl(
142                include_str!("shaders/sprite.wgsl").into(),
143            ),
144        });
145
146        // Camera uniform bind group layout (group 0)
147        let camera_bind_group_layout =
148            gpu.device
149                .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
150                    label: Some("camera_bind_group_layout"),
151                    entries: &[wgpu::BindGroupLayoutEntry {
152                        binding: 0,
153                        visibility: wgpu::ShaderStages::VERTEX,
154                        ty: wgpu::BindingType::Buffer {
155                            ty: wgpu::BufferBindingType::Uniform,
156                            has_dynamic_offset: false,
157                            min_binding_size: None,
158                        },
159                        count: None,
160                    }],
161                });
162
163        // Texture bind group layout (group 1)
164        let texture_bind_group_layout =
165            gpu.device
166                .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
167                    label: Some("texture_bind_group_layout"),
168                    entries: &[
169                        wgpu::BindGroupLayoutEntry {
170                            binding: 0,
171                            visibility: wgpu::ShaderStages::FRAGMENT,
172                            ty: wgpu::BindingType::Texture {
173                                multisampled: false,
174                                view_dimension: wgpu::TextureViewDimension::D2,
175                                sample_type: wgpu::TextureSampleType::Float { filterable: true },
176                            },
177                            count: None,
178                        },
179                        wgpu::BindGroupLayoutEntry {
180                            binding: 1,
181                            visibility: wgpu::ShaderStages::FRAGMENT,
182                            ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
183                            count: None,
184                        },
185                    ],
186                });
187
188        // Lighting uniform bind group layout (group 2)
189        let lighting_bind_group_layout =
190            gpu.device
191                .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
192                    label: Some("lighting_bind_group_layout"),
193                    entries: &[wgpu::BindGroupLayoutEntry {
194                        binding: 0,
195                        visibility: wgpu::ShaderStages::FRAGMENT,
196                        ty: wgpu::BindingType::Buffer {
197                            ty: wgpu::BufferBindingType::Uniform,
198                            has_dynamic_offset: false,
199                            min_binding_size: None,
200                        },
201                        count: None,
202                    }],
203                });
204
205        let pipeline_layout =
206            gpu.device
207                .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
208                    label: Some("sprite_pipeline_layout"),
209                    bind_group_layouts: &[
210                        &camera_bind_group_layout,
211                        &texture_bind_group_layout,
212                        &lighting_bind_group_layout,
213                    ],
214                    push_constant_ranges: &[],
215                });
216
217        // Vertex buffer layouts
218        let vertex_layout = wgpu::VertexBufferLayout {
219            array_stride: std::mem::size_of::<QuadVertex>() as wgpu::BufferAddress,
220            step_mode: wgpu::VertexStepMode::Vertex,
221            attributes: &[
222                wgpu::VertexAttribute {
223                    offset: 0,
224                    shader_location: 0,
225                    format: wgpu::VertexFormat::Float32x2,
226                },
227                wgpu::VertexAttribute {
228                    offset: 8,
229                    shader_location: 1,
230                    format: wgpu::VertexFormat::Float32x2,
231                },
232            ],
233        };
234
235        let instance_layout = wgpu::VertexBufferLayout {
236            array_stride: std::mem::size_of::<SpriteInstance>() as wgpu::BufferAddress,
237            step_mode: wgpu::VertexStepMode::Instance,
238            attributes: &[
239                wgpu::VertexAttribute {
240                    offset: 0,
241                    shader_location: 2,
242                    format: wgpu::VertexFormat::Float32x2, // world_pos
243                },
244                wgpu::VertexAttribute {
245                    offset: 8,
246                    shader_location: 3,
247                    format: wgpu::VertexFormat::Float32x2, // size
248                },
249                wgpu::VertexAttribute {
250                    offset: 16,
251                    shader_location: 4,
252                    format: wgpu::VertexFormat::Float32x2, // uv_offset
253                },
254                wgpu::VertexAttribute {
255                    offset: 24,
256                    shader_location: 5,
257                    format: wgpu::VertexFormat::Float32x2, // uv_size
258                },
259                wgpu::VertexAttribute {
260                    offset: 32,
261                    shader_location: 6,
262                    format: wgpu::VertexFormat::Float32x4, // tint
263                },
264                wgpu::VertexAttribute {
265                    offset: 48,
266                    shader_location: 7,
267                    format: wgpu::VertexFormat::Float32x4, // rotation_origin
268                },
269            ],
270        };
271
272        // Create one pipeline per blend mode
273        let blend_names = ["alpha", "additive", "multiply", "screen"];
274        let pipelines: Vec<wgpu::RenderPipeline> = (0..4u8)
275            .map(|mode| {
276                gpu.device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
277                    label: Some(&format!("sprite_pipeline_{}", blend_names[mode as usize])),
278                    layout: Some(&pipeline_layout),
279                    vertex: wgpu::VertexState {
280                        module: &shader,
281                        entry_point: Some("vs_main"),
282                        buffers: &[vertex_layout.clone(), instance_layout.clone()],
283                        compilation_options: Default::default(),
284                    },
285                    fragment: Some(wgpu::FragmentState {
286                        module: &shader,
287                        entry_point: Some("fs_main"),
288                        targets: &[Some(wgpu::ColorTargetState {
289                            format: gpu.config.format,
290                            blend: Some(blend_state_for(mode)),
291                            write_mask: wgpu::ColorWrites::ALL,
292                        })],
293                        compilation_options: Default::default(),
294                    }),
295                    primitive: wgpu::PrimitiveState {
296                        topology: wgpu::PrimitiveTopology::TriangleList,
297                        strip_index_format: None,
298                        front_face: wgpu::FrontFace::Ccw,
299                        cull_mode: None,
300                        polygon_mode: wgpu::PolygonMode::Fill,
301                        unclipped_depth: false,
302                        conservative: false,
303                    },
304                    depth_stencil: None,
305                    multisample: wgpu::MultisampleState::default(),
306                    multiview: None,
307                    cache: None,
308                })
309            })
310            .collect();
311
312        let pipelines: [wgpu::RenderPipeline; 4] = pipelines.try_into().unwrap();
313
314        let vertex_buffer = gpu.device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
315            label: Some("quad_vertex_buffer"),
316            contents: bytemuck::cast_slice(QUAD_VERTICES),
317            usage: wgpu::BufferUsages::VERTEX,
318        });
319
320        let index_buffer = gpu.device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
321            label: Some("quad_index_buffer"),
322            contents: bytemuck::cast_slice(QUAD_INDICES),
323            usage: wgpu::BufferUsages::INDEX,
324        });
325
326        let camera_uniform = CameraUniform {
327            view_proj: Camera2D::default().view_proj(),
328        };
329
330        let camera_buffer = gpu.device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
331            label: Some("camera_uniform_buffer"),
332            contents: bytemuck::cast_slice(&[camera_uniform]),
333            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
334        });
335
336        let camera_bind_group = gpu.device.create_bind_group(&wgpu::BindGroupDescriptor {
337            label: Some("camera_bind_group"),
338            layout: &camera_bind_group_layout,
339            entries: &[wgpu::BindGroupEntry {
340                binding: 0,
341                resource: camera_buffer.as_entire_binding(),
342            }],
343        });
344
345        // Lighting uniform buffer (272 bytes = LightingUniform size)
346        let default_lighting = LightingUniform {
347            ambient: [1.0, 1.0, 1.0],
348            light_count: 0,
349            lights: [super::lighting::LightData {
350                pos_radius: [0.0; 4],
351                color_intensity: [0.0; 4],
352            }; super::lighting::MAX_LIGHTS],
353        };
354
355        let lighting_buffer = gpu.device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
356            label: Some("lighting_uniform_buffer"),
357            contents: bytemuck::cast_slice(&[default_lighting]),
358            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
359        });
360
361        let lighting_bind_group = gpu.device.create_bind_group(&wgpu::BindGroupDescriptor {
362            label: Some("lighting_bind_group"),
363            layout: &lighting_bind_group_layout,
364            entries: &[wgpu::BindGroupEntry {
365                binding: 0,
366                resource: lighting_buffer.as_entire_binding(),
367            }],
368        });
369
370        Self {
371            pipelines,
372            vertex_buffer,
373            index_buffer,
374            camera_buffer,
375            camera_bind_group,
376            texture_bind_group_layout,
377            lighting_buffer,
378            lighting_bind_group,
379        }
380    }
381
382    /// Render a sorted list of sprite commands.
383    /// Commands should be sorted by layer → shader_id → blend_mode → texture_id.
384    pub fn render(
385        &self,
386        gpu: &GpuContext,
387        textures: &TextureStore,
388        shaders: &super::shader::ShaderStore,
389        camera: &Camera2D,
390        lighting: &LightingUniform,
391        commands: &[SpriteCommand],
392        target: &wgpu::TextureView,
393        encoder: &mut wgpu::CommandEncoder,
394        clear_color: wgpu::Color,
395    ) {
396        // Update camera uniform
397        let camera_uniform = CameraUniform {
398            view_proj: camera.view_proj(),
399        };
400        gpu.queue.write_buffer(
401            &self.camera_buffer,
402            0,
403            bytemuck::cast_slice(&[camera_uniform]),
404        );
405
406        // Update lighting uniform
407        gpu.queue.write_buffer(
408            &self.lighting_buffer,
409            0,
410            bytemuck::cast_slice(&[*lighting]),
411        );
412
413        let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
414            label: Some("sprite_render_pass"),
415            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
416                view: target,
417                resolve_target: None,
418                ops: wgpu::Operations {
419                    load: wgpu::LoadOp::Clear(clear_color),
420                    store: wgpu::StoreOp::Store,
421                },
422            })],
423            depth_stencil_attachment: None,
424            timestamp_writes: None,
425            occlusion_query_set: None,
426        });
427
428        render_pass.set_bind_group(0, &self.camera_bind_group, &[]);
429        render_pass.set_bind_group(2, &self.lighting_bind_group, &[]);
430        render_pass.set_vertex_buffer(0, self.vertex_buffer.slice(..));
431        render_pass.set_index_buffer(self.index_buffer.slice(..), wgpu::IndexFormat::Uint16);
432
433        // Batch by shader_id + blend_mode + texture_id (commands pre-sorted)
434        let mut current_shader: Option<u32> = None;
435        let mut current_blend: Option<u8> = None;
436        let mut i = 0;
437        while i < commands.len() {
438            let shader = commands[i].shader_id;
439            let blend = commands[i].blend_mode.min(3);
440            let tex_id = commands[i].texture_id;
441            let batch_start = i;
442            while i < commands.len()
443                && commands[i].shader_id == shader
444                && commands[i].blend_mode.min(3) == blend
445                && commands[i].texture_id == tex_id
446            {
447                i += 1;
448            }
449            let batch = &commands[batch_start..i];
450
451            // Switch pipeline: built-in (shader_id 0) vs custom
452            if shader == 0 {
453                if current_shader != Some(0) || current_blend != Some(blend) {
454                    render_pass.set_pipeline(&self.pipelines[blend as usize]);
455                    current_shader = Some(0);
456                    current_blend = Some(blend);
457                }
458            } else if current_shader != Some(shader) {
459                if let Some(pipeline) = shaders.get_pipeline(shader) {
460                    render_pass.set_pipeline(pipeline);
461                    if let Some(bg) = shaders.get_bind_group(shader) {
462                        render_pass.set_bind_group(3, bg, &[]);
463                    }
464                    current_shader = Some(shader);
465                    current_blend = None;
466                } else {
467                    continue; // skip batch if shader not loaded
468                }
469            }
470
471            // Get texture bind group
472            let bind_group = match textures.get_bind_group(tex_id) {
473                Some(bg) => bg,
474                None => continue, // skip if texture not loaded
475            };
476
477            // Build instance buffer for this batch
478            let instances: Vec<SpriteInstance> = batch
479                .iter()
480                .map(|cmd| {
481                    // Apply flip by negating UV and shifting offset
482                    let mut uv_x = cmd.uv_x;
483                    let mut uv_y = cmd.uv_y;
484                    let mut uv_w = cmd.uv_w;
485                    let mut uv_h = cmd.uv_h;
486                    if cmd.flip_x {
487                        uv_x += uv_w;
488                        uv_w = -uv_w;
489                    }
490                    if cmd.flip_y {
491                        uv_y += uv_h;
492                        uv_h = -uv_h;
493                    }
494                    SpriteInstance {
495                        world_pos: [cmd.x, cmd.y],
496                        size: [cmd.w, cmd.h],
497                        uv_offset: [uv_x, uv_y],
498                        uv_size: [uv_w, uv_h],
499                        tint: [cmd.tint_r, cmd.tint_g, cmd.tint_b, cmd.tint_a * cmd.opacity],
500                        rotation_origin: [cmd.rotation, cmd.origin_x, cmd.origin_y, 0.0],
501                    }
502                })
503                .collect();
504
505            let instance_buffer =
506                gpu.device
507                    .create_buffer_init(&wgpu::util::BufferInitDescriptor {
508                        label: Some("sprite_instance_buffer"),
509                        contents: bytemuck::cast_slice(&instances),
510                        usage: wgpu::BufferUsages::VERTEX,
511                    });
512
513            render_pass.set_bind_group(1, bind_group, &[]);
514            render_pass.set_vertex_buffer(1, instance_buffer.slice(..));
515            render_pass.draw_indexed(0..6, 0, 0..instances.len() as u32);
516        }
517    }
518}