Skip to main content

arcane_core/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    /// Create a sprite pipeline for headless testing.
139    /// Takes raw GPU components instead of GpuContext (which requires a surface).
140    pub fn new_headless(
141        device: &wgpu::Device,
142        queue: &wgpu::Queue,
143        format: wgpu::TextureFormat,
144    ) -> Self {
145        Self::new_internal(device, queue, format)
146    }
147
148    pub fn new(gpu: &GpuContext) -> Self {
149        Self::new_internal(&gpu.device, &gpu.queue, gpu.config.format)
150    }
151
152    fn new_internal(
153        device: &wgpu::Device,
154        _queue: &wgpu::Queue,
155        surface_format: wgpu::TextureFormat,
156    ) -> Self {
157        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
158            label: Some("sprite_shader"),
159            source: wgpu::ShaderSource::Wgsl(
160                include_str!("shaders/sprite.wgsl").into(),
161            ),
162        });
163
164        // Camera uniform bind group layout (group 0)
165        let camera_bind_group_layout =
166            device
167                .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
168                    label: Some("camera_bind_group_layout"),
169                    entries: &[wgpu::BindGroupLayoutEntry {
170                        binding: 0,
171                        visibility: wgpu::ShaderStages::VERTEX,
172                        ty: wgpu::BindingType::Buffer {
173                            ty: wgpu::BufferBindingType::Uniform,
174                            has_dynamic_offset: false,
175                            min_binding_size: None,
176                        },
177                        count: None,
178                    }],
179                });
180
181        // Texture bind group layout (group 1)
182        let texture_bind_group_layout =
183            device
184                .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
185                    label: Some("texture_bind_group_layout"),
186                    entries: &[
187                        wgpu::BindGroupLayoutEntry {
188                            binding: 0,
189                            visibility: wgpu::ShaderStages::FRAGMENT,
190                            ty: wgpu::BindingType::Texture {
191                                multisampled: false,
192                                view_dimension: wgpu::TextureViewDimension::D2,
193                                sample_type: wgpu::TextureSampleType::Float { filterable: true },
194                            },
195                            count: None,
196                        },
197                        wgpu::BindGroupLayoutEntry {
198                            binding: 1,
199                            visibility: wgpu::ShaderStages::FRAGMENT,
200                            ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
201                            count: None,
202                        },
203                    ],
204                });
205
206        // Lighting uniform bind group layout (group 2)
207        let lighting_bind_group_layout =
208            device
209                .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
210                    label: Some("lighting_bind_group_layout"),
211                    entries: &[wgpu::BindGroupLayoutEntry {
212                        binding: 0,
213                        visibility: wgpu::ShaderStages::FRAGMENT,
214                        ty: wgpu::BindingType::Buffer {
215                            ty: wgpu::BufferBindingType::Uniform,
216                            has_dynamic_offset: false,
217                            min_binding_size: None,
218                        },
219                        count: None,
220                    }],
221                });
222
223        let pipeline_layout =
224            device
225                .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
226                    label: Some("sprite_pipeline_layout"),
227                    bind_group_layouts: &[
228                        &camera_bind_group_layout,
229                        &texture_bind_group_layout,
230                        &lighting_bind_group_layout,
231                    ],
232                    push_constant_ranges: &[],
233                });
234
235        // Vertex buffer layouts
236        let vertex_layout = wgpu::VertexBufferLayout {
237            array_stride: std::mem::size_of::<QuadVertex>() as wgpu::BufferAddress,
238            step_mode: wgpu::VertexStepMode::Vertex,
239            attributes: &[
240                wgpu::VertexAttribute {
241                    offset: 0,
242                    shader_location: 0,
243                    format: wgpu::VertexFormat::Float32x2,
244                },
245                wgpu::VertexAttribute {
246                    offset: 8,
247                    shader_location: 1,
248                    format: wgpu::VertexFormat::Float32x2,
249                },
250            ],
251        };
252
253        let instance_layout = wgpu::VertexBufferLayout {
254            array_stride: std::mem::size_of::<SpriteInstance>() as wgpu::BufferAddress,
255            step_mode: wgpu::VertexStepMode::Instance,
256            attributes: &[
257                wgpu::VertexAttribute {
258                    offset: 0,
259                    shader_location: 2,
260                    format: wgpu::VertexFormat::Float32x2, // world_pos
261                },
262                wgpu::VertexAttribute {
263                    offset: 8,
264                    shader_location: 3,
265                    format: wgpu::VertexFormat::Float32x2, // size
266                },
267                wgpu::VertexAttribute {
268                    offset: 16,
269                    shader_location: 4,
270                    format: wgpu::VertexFormat::Float32x2, // uv_offset
271                },
272                wgpu::VertexAttribute {
273                    offset: 24,
274                    shader_location: 5,
275                    format: wgpu::VertexFormat::Float32x2, // uv_size
276                },
277                wgpu::VertexAttribute {
278                    offset: 32,
279                    shader_location: 6,
280                    format: wgpu::VertexFormat::Float32x4, // tint
281                },
282                wgpu::VertexAttribute {
283                    offset: 48,
284                    shader_location: 7,
285                    format: wgpu::VertexFormat::Float32x4, // rotation_origin
286                },
287            ],
288        };
289
290        // Create one pipeline per blend mode
291        let blend_names = ["alpha", "additive", "multiply", "screen"];
292        let pipelines: Vec<wgpu::RenderPipeline> = (0..4u8)
293            .map(|mode| {
294                device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
295                    label: Some(&format!("sprite_pipeline_{}", blend_names[mode as usize])),
296                    layout: Some(&pipeline_layout),
297                    vertex: wgpu::VertexState {
298                        module: &shader,
299                        entry_point: Some("vs_main"),
300                        buffers: &[vertex_layout.clone(), instance_layout.clone()],
301                        compilation_options: Default::default(),
302                    },
303                    fragment: Some(wgpu::FragmentState {
304                        module: &shader,
305                        entry_point: Some("fs_main"),
306                        targets: &[Some(wgpu::ColorTargetState {
307                            format: surface_format,
308                            blend: Some(blend_state_for(mode)),
309                            write_mask: wgpu::ColorWrites::ALL,
310                        })],
311                        compilation_options: Default::default(),
312                    }),
313                    primitive: wgpu::PrimitiveState {
314                        topology: wgpu::PrimitiveTopology::TriangleList,
315                        strip_index_format: None,
316                        front_face: wgpu::FrontFace::Ccw,
317                        cull_mode: None,
318                        polygon_mode: wgpu::PolygonMode::Fill,
319                        unclipped_depth: false,
320                        conservative: false,
321                    },
322                    depth_stencil: None,
323                    multisample: wgpu::MultisampleState::default(),
324                    multiview: None,
325                    cache: None,
326                })
327            })
328            .collect();
329
330        let pipelines: [wgpu::RenderPipeline; 4] = pipelines.try_into().unwrap();
331
332        let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
333            label: Some("quad_vertex_buffer"),
334            contents: bytemuck::cast_slice(QUAD_VERTICES),
335            usage: wgpu::BufferUsages::VERTEX,
336        });
337
338        let index_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
339            label: Some("quad_index_buffer"),
340            contents: bytemuck::cast_slice(QUAD_INDICES),
341            usage: wgpu::BufferUsages::INDEX,
342        });
343
344        let camera_uniform = CameraUniform {
345            view_proj: Camera2D::default().view_proj(),
346        };
347
348        let camera_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
349            label: Some("camera_uniform_buffer"),
350            contents: bytemuck::cast_slice(&[camera_uniform]),
351            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
352        });
353
354        let camera_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
355            label: Some("camera_bind_group"),
356            layout: &camera_bind_group_layout,
357            entries: &[wgpu::BindGroupEntry {
358                binding: 0,
359                resource: camera_buffer.as_entire_binding(),
360            }],
361        });
362
363        // Lighting uniform buffer (272 bytes = LightingUniform size)
364        let default_lighting = LightingUniform {
365            ambient: [1.0, 1.0, 1.0],
366            light_count: 0,
367            lights: [super::lighting::LightData {
368                pos_radius: [0.0; 4],
369                color_intensity: [0.0; 4],
370            }; super::lighting::MAX_LIGHTS],
371        };
372
373        let lighting_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
374            label: Some("lighting_uniform_buffer"),
375            contents: bytemuck::cast_slice(&[default_lighting]),
376            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
377        });
378
379        let lighting_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
380            label: Some("lighting_bind_group"),
381            layout: &lighting_bind_group_layout,
382            entries: &[wgpu::BindGroupEntry {
383                binding: 0,
384                resource: lighting_buffer.as_entire_binding(),
385            }],
386        });
387
388        Self {
389            pipelines,
390            vertex_buffer,
391            index_buffer,
392            camera_buffer,
393            camera_bind_group,
394            texture_bind_group_layout,
395            lighting_buffer,
396            lighting_bind_group,
397        }
398    }
399
400    /// Return the camera uniform bind group (group 0).
401    /// Used by other pipelines (e.g. GeometryBatch) that share the same view-proj matrix.
402    pub fn camera_bind_group(&self) -> &wgpu::BindGroup {
403        &self.camera_bind_group
404    }
405
406    /// Write camera and lighting uniforms to GPU buffers. Call once per frame
407    /// before any `render()` calls to avoid redundant buffer writes.
408    pub fn prepare(
409        &self,
410        device: &wgpu::Device,
411        queue: &wgpu::Queue,
412        camera: &Camera2D,
413        lighting: &LightingUniform,
414    ) {
415        let _ = device; // device not needed for buffer writes, but kept for consistency
416        let camera_uniform = CameraUniform {
417            view_proj: camera.view_proj(),
418        };
419        queue.write_buffer(
420            &self.camera_buffer,
421            0,
422            bytemuck::cast_slice(&[camera_uniform]),
423        );
424        queue.write_buffer(
425            &self.lighting_buffer,
426            0,
427            bytemuck::cast_slice(&[*lighting]),
428        );
429    }
430
431    /// Render a sorted list of sprite commands.
432    /// Commands should be sorted by layer → shader_id → blend_mode → texture_id.
433    ///
434    /// `clear_color`: `Some(color)` → `LoadOp::Clear(color)` (first pass),
435    ///                 `None` → `LoadOp::Load` (subsequent passes).
436    pub fn render(
437        &self,
438        device: &wgpu::Device,
439        _queue: &wgpu::Queue,
440        textures: &TextureStore,
441        shaders: &super::shader::ShaderStore,
442        commands: &[SpriteCommand],
443        target: &wgpu::TextureView,
444        encoder: &mut wgpu::CommandEncoder,
445        clear_color: Option<wgpu::Color>,
446    ) {
447        let load_op = match clear_color {
448            Some(color) => wgpu::LoadOp::Clear(color),
449            None => wgpu::LoadOp::Load,
450        };
451
452        let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
453            label: Some("sprite_render_pass"),
454            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
455                view: target,
456                resolve_target: None,
457                ops: wgpu::Operations {
458                    load: load_op,
459                    store: wgpu::StoreOp::Store,
460                },
461            })],
462            depth_stencil_attachment: None,
463            timestamp_writes: None,
464            occlusion_query_set: None,
465        });
466
467        render_pass.set_bind_group(0, &self.camera_bind_group, &[]);
468        render_pass.set_bind_group(2, &self.lighting_bind_group, &[]);
469        render_pass.set_vertex_buffer(0, self.vertex_buffer.slice(..));
470        render_pass.set_index_buffer(self.index_buffer.slice(..), wgpu::IndexFormat::Uint16);
471
472        // Batch by shader_id + blend_mode + texture_id (commands pre-sorted)
473        let mut current_shader: Option<u32> = None;
474        let mut current_blend: Option<u8> = None;
475        let mut i = 0;
476        while i < commands.len() {
477            let shader = commands[i].shader_id;
478            let blend = commands[i].blend_mode.min(3);
479            let tex_id = commands[i].texture_id;
480            let batch_start = i;
481            while i < commands.len()
482                && commands[i].shader_id == shader
483                && commands[i].blend_mode.min(3) == blend
484                && commands[i].texture_id == tex_id
485            {
486                i += 1;
487            }
488            let batch = &commands[batch_start..i];
489
490            // Switch pipeline: built-in (shader_id 0) vs custom
491            if shader == 0 {
492                if current_shader != Some(0) || current_blend != Some(blend) {
493                    render_pass.set_pipeline(&self.pipelines[blend as usize]);
494                    current_shader = Some(0);
495                    current_blend = Some(blend);
496                }
497            } else if current_shader != Some(shader) {
498                if let Some(pipeline) = shaders.get_pipeline(shader) {
499                    render_pass.set_pipeline(pipeline);
500                    if let Some(bg) = shaders.get_bind_group(shader) {
501                        render_pass.set_bind_group(3, bg, &[]);
502                    }
503                    current_shader = Some(shader);
504                    current_blend = None;
505                } else {
506                    continue; // skip batch if shader not loaded
507                }
508            }
509
510            // Get texture bind group
511            let bind_group = match textures.get_bind_group(tex_id) {
512                Some(bg) => bg,
513                None => continue, // skip if texture not loaded
514            };
515
516            // Build instance buffer for this batch
517            let instances: Vec<SpriteInstance> = batch
518                .iter()
519                .map(|cmd| {
520                    // Apply flip by negating UV and shifting offset
521                    let mut uv_x = cmd.uv_x;
522                    let mut uv_y = cmd.uv_y;
523                    let mut uv_w = cmd.uv_w;
524                    let mut uv_h = cmd.uv_h;
525                    if cmd.flip_x {
526                        uv_x += uv_w;
527                        uv_w = -uv_w;
528                    }
529                    if cmd.flip_y {
530                        uv_y += uv_h;
531                        uv_h = -uv_h;
532                    }
533                    SpriteInstance {
534                        world_pos: [cmd.x, cmd.y],
535                        size: [cmd.w, cmd.h],
536                        uv_offset: [uv_x, uv_y],
537                        uv_size: [uv_w, uv_h],
538                        tint: [cmd.tint_r, cmd.tint_g, cmd.tint_b, cmd.tint_a * cmd.opacity],
539                        rotation_origin: [cmd.rotation, cmd.origin_x, cmd.origin_y, 0.0],
540                    }
541                })
542                .collect();
543
544            let instance_buffer =
545                device
546                    .create_buffer_init(&wgpu::util::BufferInitDescriptor {
547                        label: Some("sprite_instance_buffer"),
548                        contents: bytemuck::cast_slice(&instances),
549                        usage: wgpu::BufferUsages::VERTEX,
550                    });
551
552            render_pass.set_bind_group(1, bind_group, &[]);
553            render_pass.set_vertex_buffer(1, instance_buffer.slice(..));
554            render_pass.draw_indexed(0..6, 0, 0..instances.len() as u32);
555        }
556    }
557}