Skip to main content

repame_sprite/
batch.rs

1//! GPU sprite batch: instanced textured quads as a [`WgpuCallback`].
2//! Owns the atlas texture array and instance buffer. Feeds from
3//! [`AtlasUpload`]s, draws through a caller-supplied camera matrix.
4//!
5//! ```ignore
6//! let mut batch = SpriteBatch::new(BatchDesc::default());
7//! batch.set_camera(screen_camera([1920.0, 1080.0]));
8//! for s in &input.sprites {
9//!     batch.push_sprite(s);
10//! }
11//! Embedded(Modifier::new().fill_max_size(), Callback::new(batch))
12//! ```
13//!
14//! NOTE: World space is y-down (canvas/snapshot convention). Instance rows map
15//! quad corners to world coords; the camera uniform maps world to clip.
16//! Draw order is `z`-sorted stable (Bevy `z` semantics) *within* each blend
17//! range, then ranges draw alpha, multiply, additive: push order across
18//! blend modes is NOT preserved (an additive muzzle flash pushed under an
19//! alpha smoke sprite still composites over it). Budget `z` knowing the
20//! ranges reorder: interleaved alpha/additive scenes cannot rely on push
21//! order across the blend boundary. Each batch id owns its pipelines in
22//! `CallbackResources` (like `FullscreenPass`'s id map), so viewports +
23//! minimaps coexist.
24
25use std::collections::HashMap;
26
27use bytemuck::{Pod, Zeroable};
28use glam::Mat4;
29use repose_render_wgpu::{CallbackResources, ScreenDescriptor, WgpuCallback};
30use wgpu::util::DeviceExt;
31
32use super::{SpriteBlend, SpriteInstance};
33
34/// Texture sampling for atlas layers.
35#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
36pub enum TextureFilter {
37    /// Pixel-art crisp (nt default).
38    #[default]
39    Nearest,
40    Linear,
41}
42
43/// Batch construction parameters. Pages map 1:1 onto array layers.
44#[derive(Clone, Copy, Debug)]
45pub struct BatchDesc {
46    /// Square atlas layer edge in pixels.
47    pub layer_size: u32,
48    /// Array layer count (= max atlas pages).
49    pub layers: u32,
50    pub filter: TextureFilter,
51}
52
53impl Default for BatchDesc {
54    fn default() -> Self {
55        Self {
56            layer_size: 2048,
57            layers: 4,
58            filter: TextureFilter::Nearest,
59        }
60    }
61}
62
63/// One pending atlas upload: blit `rgba` (tight `w`*`h`*4 bytes) into
64/// `page` at (`x`, `y`). Built from a `repame-atlas` drain by the game.
65#[derive(Clone, Debug)]
66pub struct AtlasUpload {
67    pub page: u32,
68    pub x: u32,
69    pub y: u32,
70    pub w: u32,
71    pub h: u32,
72    pub rgba: Vec<u8>,
73}
74
75impl AtlasUpload {
76    /// Bridge an atlas placement to a GPU upload. `rgba` must hold the
77    /// sprite's pixels row-major, `w`*`h`*4 bytes; mismatches are dropped
78    /// with a warning at `prepare` time.
79    pub fn from_write(write: &repame_atlas::AtlasWrite, rgba: Vec<u8>) -> Self {
80        Self {
81            page: write.page,
82            x: write.x,
83            y: write.y,
84            w: write.w,
85            h: write.h,
86            rgba,
87        }
88    }
89
90    /// 1x1 white texel upload for [`repame_atlas::Atlas::ensure_white`]:
91    /// pair with the drained write for the white key (usually 1x1 at the
92    /// returned `x/y/page`). Sampling it gives solid tint (particles,
93    /// untextured quads) instead of random atlas art.
94    pub fn white(page: u32, x: u32, y: u32) -> Self {
95        Self {
96            page,
97            x,
98            y,
99            w: 1,
100            h: 1,
101            rgba: vec![255, 255, 255, 255],
102        }
103    }
104
105    /// White upload built straight from the drained [`repame_atlas::AtlasWrite`]
106    /// for the white key.
107    pub fn white_for(write: &repame_atlas::AtlasWrite) -> Self {
108        Self {
109            page: write.page,
110            x: write.x,
111            y: write.y,
112            w: write.w.max(1),
113            h: write.h.max(1),
114            rgba: vec![255; write.w.max(1) as usize * write.h.max(1) as usize * 4],
115        }
116    }
117}
118
119/// Transform rows mapping quad corners (-0.5..0.5) to world coords,
120/// honoring size, rotation, anchor, and mirror. Matches the canvas path
121/// pixel-for-pixel at the default anchor: corner (-0.5,-0.5) lands on
122/// `center - size / 2`.
123pub fn instance_rows(
124    center: [f32; 2],
125    size: [f32; 2],
126    rotation: f32,
127    anchor: [f32; 2],
128    flip_x: bool,
129    flip_y: bool,
130) -> ([f32; 4], [f32; 4]) {
131    let (c, s) = (rotation.cos(), rotation.sin());
132    let sx = if flip_x { -size[0] } else { size[0] };
133    let sy = if flip_y { -size[1] } else { size[1] };
134    // Normalized offset of the anchor from the quad middle: the anchor
135    // point itself must land on `center`.
136    let (ax, ay) = (0.5 - anchor[0], 0.5 - anchor[1]);
137    (
138        [c * sx, -s * sy, 0.0, center[0] + c * sx * ax - s * sy * ay],
139        [s * sx, c * sy, 0.0, center[1] + s * sx * ax + c * sy * ay],
140    )
141}
142
143/// UV rect for one cell of a sprite-sheet grid: `hframes` columns by
144/// `vframes` rows, `frame` counted row-major from the top-left.
145///
146/// Returns `(uv_min, uv_max)` normalized to `0..1`, ready for
147/// [`SpriteInstance`] `uv_min`/`uv_max`. The grid
148/// covers the whole texture: cell `(col, row)` spans
149/// `col/hframes..(col+1)/hframes` by `row/vframes..(row+1)/vframes`.
150///
151/// - Out-of-range `frame` values clamp to the last cell instead of
152///   wrapping; advance the frame index with your animation clock.
153/// - Degenerate grids (`hframes` or `vframes` of `0`) yield the full
154///   texture, matching a single-frame sprite.
155///
156/// ```rust
157/// use repame_sprite::{frame_uv, sprite_aabb};
158///
159/// // 4x2 sheet: frame 5 is column 1, row 1.
160/// let (mn, mx) = frame_uv(4, 2, 5);
161/// assert_eq!(mn, [0.25, 0.5]);
162/// assert_eq!(mx, [0.5, 1.0]);
163/// ```
164pub fn frame_uv(hframes: u32, vframes: u32, frame: u32) -> ([f32; 2], [f32; 2]) {
165    let hf = hframes.max(1);
166    let vf = vframes.max(1);
167    let f = frame.min(hf * vf - 1);
168    let col = f % hf;
169    let row = f / hf;
170    (
171        [col as f32 / hf as f32, row as f32 / vf as f32],
172        [(col + 1) as f32 / hf as f32, (row + 1) as f32 / vf as f32],
173    )
174}
175
176/// World-space axis-aligned bounds of a sprite quad.
177///
178/// Returns `([min_x, min_y], [max_x, max_y])` through the same
179/// [`instance_rows`] math as the GPU batch, so the box matches the drawn
180/// quad: exact for unrotated sprites, the outer box for rotated ones.
181/// `flip_x`/`flip_y` do not change the box (mirroring preserves extents).
182///
183/// Useful for click hit-testing and layout: a press at world point `p`
184/// hits the sprite when `min <= p <= max`.
185///
186/// ```rust
187/// use repame_sprite::{frame_uv, sprite_aabb};
188///
189/// let (mn, mx) = sprite_aabb([400.0, 300.0], [64.0, 80.0], 0.0, [0.5, 0.5], false, false);
190/// assert_eq!(mn, [368.0, 260.0]);
191/// assert_eq!(mx, [432.0, 340.0]);
192/// ```
193pub fn sprite_aabb(
194    center: [f32; 2],
195    size: [f32; 2],
196    rotation: f32,
197    anchor: [f32; 2],
198    flip_x: bool,
199    flip_y: bool,
200) -> ([f32; 2], [f32; 2]) {
201    let (row0, row1) = instance_rows(center, size, rotation, anchor, flip_x, flip_y);
202    let mut min = [f32::INFINITY, f32::INFINITY];
203    let mut max = [f32::NEG_INFINITY, f32::NEG_INFINITY];
204    for corner in [[-0.5, -0.5], [0.5, -0.5], [0.5, 0.5], [-0.5, 0.5]] {
205        let x = row0[0] * corner[0] + row0[1] * corner[1] + row0[3];
206        let y = row1[0] * corner[0] + row1[1] * corner[1] + row1[3];
207        min[0] = min[0].min(x);
208        min[1] = min[1].min(y);
209        max[0] = max[0].max(x);
210        max[1] = max[1].max(y);
211    }
212    (min, max)
213}
214
215/// Y-down orthographic camera: world (0,0) is the top-left of the
216/// viewport, matching canvas orientation by construction. Standalone
217/// batches and tests use this directly; viewport snapshots frame through
218/// `fit_view_proj` (contain-fit + look) instead.
219pub fn screen_camera(viewport_px: [f32; 2]) -> Mat4 {
220    // Right-handed, 0..1 depth: matches wgpu NDC.
221    glam::camera::rh::proj::directx::orthographic(
222        0.0,
223        viewport_px[0].max(1.0),
224        viewport_px[1].max(1.0),
225        0.0,
226        -1000.0,
227        1000.0,
228    )
229}
230
231#[repr(C)]
232#[derive(Clone, Copy, Pod, Zeroable)]
233struct CameraUniform {
234    view_proj: [[f32; 4]; 4],
235}
236
237#[repr(C)]
238#[derive(Clone, Copy, Pod, Zeroable)]
239struct BatchInstance {
240    row0: [f32; 4],
241    row1: [f32; 4],
242    uv_min: [f32; 2],
243    uv_max: [f32; 2],
244    tint: [f32; 4],
245    page: f32,
246    z: f32,
247    flags: u32,
248    _pad: u32,
249}
250
251// Locked to `shaders/sprite.wgsl::Instance` (row0@0, row1@16,
252// uv_min@32, uv_max@40, tint@48, page@64, z@68, flags@72; stride 80).
253// If this fires, update the WGSL offsets and the vertex buffer layout
254// below together.
255const _: () = assert!(size_of::<BatchInstance>() == 80);
256
257/// Per-frame snapshot batch. `Send + Sync` so it can cross into the
258/// compositor thread via [`repose_render_wgpu::Callback`].
259///
260/// Each batch id owns its pipelines/instances in `CallbackResources`
261/// (like `FullscreenPass`'s id map). Rebuilding on format/sample/desc
262/// change drops atlas contents (logged); the game must re-upload
263/// afterwards.
264pub struct SpriteBatch {
265    id: String,
266    desc: BatchDesc,
267    camera: [[f32; 4]; 4],
268    instances: Vec<BatchInstance>,
269    uploads: Vec<AtlasUpload>,
270}
271
272impl SpriteBatch {
273    pub fn new(desc: BatchDesc) -> Self {
274        Self::with_id("sprite_batch.default", desc)
275    }
276
277    pub fn with_id(id: impl Into<String>, desc: BatchDesc) -> Self {
278        Self {
279            id: id.into(),
280            desc,
281            camera: Mat4::IDENTITY.to_cols_array_2d(),
282            instances: Vec::new(),
283            uploads: Vec::new(),
284        }
285    }
286
287    pub fn id(&self) -> &str {
288        &self.id
289    }
290
291    pub fn clear(&mut self) {
292        self.instances.clear();
293        self.uploads.clear();
294    }
295
296    pub fn len(&self) -> usize {
297        self.instances.len()
298    }
299
300    pub fn is_empty(&self) -> bool {
301        self.instances.is_empty()
302    }
303
304    /// View-projection matrix, y-down screen convention (see
305    /// [`screen_camera`]).
306    pub fn set_camera(&mut self, view_proj: Mat4) {
307        self.camera = view_proj.to_cols_array_2d();
308    }
309
310    /// Queue atlas uploads, applied in the next `prepare`.
311    pub fn upload(&mut self, upload: AtlasUpload) {
312        self.uploads.push(upload);
313    }
314
315    /// Queue several uploads at once (per-frame atlas drains).
316    pub fn extend_uploads(&mut self, uploads: impl IntoIterator<Item = AtlasUpload>) {
317        self.uploads.extend(uploads);
318    }
319
320    /// Push one textured quad.
321    #[allow(clippy::too_many_arguments)]
322    pub fn push(
323        &mut self,
324        center: [f32; 2],
325        size: [f32; 2],
326        rotation: f32,
327        anchor: [f32; 2],
328        flip_x: bool,
329        flip_y: bool,
330        uv_min: [f32; 2],
331        uv_max: [f32; 2],
332        tint: [f32; 4],
333        page: u32,
334    ) {
335        self.push_blended(
336            center,
337            size,
338            rotation,
339            anchor,
340            flip_x,
341            flip_y,
342            uv_min,
343            uv_max,
344            tint,
345            page,
346            0.0,
347            SpriteBlend::Alpha,
348        );
349    }
350
351    /// Push with explicit `z` + blend.
352    #[allow(clippy::too_many_arguments)]
353    pub fn push_blended(
354        &mut self,
355        center: [f32; 2],
356        size: [f32; 2],
357        rotation: f32,
358        anchor: [f32; 2],
359        flip_x: bool,
360        flip_y: bool,
361        uv_min: [f32; 2],
362        uv_max: [f32; 2],
363        tint: [f32; 4],
364        page: u32,
365        z: f32,
366        blend: SpriteBlend,
367    ) {
368        let (row0, row1) = instance_rows(center, size, rotation, anchor, flip_x, flip_y);
369        let flags = match blend {
370            SpriteBlend::Alpha => 0,
371            SpriteBlend::Additive => 1,
372            SpriteBlend::Multiply => 2,
373        };
374        self.instances.push(BatchInstance {
375            row0,
376            row1,
377            uv_min,
378            uv_max,
379            tint,
380            page: page as f32,
381            z,
382            flags,
383            _pad: 0,
384        });
385    }
386
387    /// Bridge a snapshot sprite into the batch.
388    pub fn push_sprite(&mut self, s: &SpriteInstance) {
389        self.push_blended(
390            [s.center.x, s.center.y],
391            [s.size.x, s.size.y],
392            s.rotation,
393            [s.anchor.x, s.anchor.y],
394            s.flip_x,
395            s.flip_y,
396            [s.uv_min.x, s.uv_min.y],
397            [s.uv_max.x, s.uv_max.y],
398            s.color,
399            s.page,
400            s.z,
401            s.blend,
402        );
403    }
404}
405
406struct BatchEntry {
407    key: (wgpu::TextureFormat, u32, u32, u32, TextureFilter),
408    pipeline_alpha: wgpu::RenderPipeline,
409    pipeline_multiply: wgpu::RenderPipeline,
410    pipeline_additive: wgpu::RenderPipeline,
411    corners: wgpu::Buffer,
412    instances: wgpu::Buffer,
413    instance_cap: usize,
414    /// Sorted ranges: `0..alpha` alpha, `alpha..multiply_end` multiply,
415    /// `multiply_end..total` additive.
416    last_alpha: u32,
417    last_multiply_end: u32,
418    last_total: u32,
419    camera: wgpu::Buffer,
420    cam_bind: wgpu::BindGroup,
421    tex_bind: wgpu::BindGroup,
422    texture: wgpu::Texture,
423}
424
425struct BatchResources {
426    batches: HashMap<String, BatchEntry>,
427}
428
429const CORNERS: &[f32] = &[
430    -0.5, -0.5, //
431    0.5, -0.5, //
432    0.5, 0.5, //
433    -0.5, -0.5, //
434    0.5, 0.5, //
435    -0.5, 0.5, //
436];
437
438fn sampler_desc(filter: TextureFilter) -> wgpu::SamplerDescriptor<'static> {
439    let f = match filter {
440        TextureFilter::Nearest => wgpu::FilterMode::Nearest,
441        TextureFilter::Linear => wgpu::FilterMode::Linear,
442    };
443    let m = match filter {
444        TextureFilter::Nearest => wgpu::MipmapFilterMode::Nearest,
445        TextureFilter::Linear => wgpu::MipmapFilterMode::Linear,
446    };
447    wgpu::SamplerDescriptor {
448        label: Some("sprite_batch_sampler"),
449        address_mode_u: wgpu::AddressMode::ClampToEdge,
450        address_mode_v: wgpu::AddressMode::ClampToEdge,
451        address_mode_w: wgpu::AddressMode::ClampToEdge,
452        mag_filter: f,
453        min_filter: f,
454        mipmap_filter: m,
455        lod_min_clamp: 0.0,
456        lod_max_clamp: 1.0,
457        compare: None,
458        anisotropy_clamp: 1,
459        border_color: None,
460    }
461}
462
463impl SpriteBatch {
464    fn ensure_resources(
465        &self,
466        device: &wgpu::Device,
467        screen: &ScreenDescriptor,
468        resources: &mut CallbackResources,
469    ) {
470        let key = (
471            screen.target_format,
472            screen.sample_count,
473            self.desc.layer_size,
474            self.desc.layers,
475            self.desc.filter,
476        );
477        let needs = match resources.get::<BatchResources>() {
478            None => true,
479            Some(all) => match all.batches.get(self.id.as_str()) {
480                None => true,
481                Some(e) => e.key != key,
482            },
483        };
484        if !needs {
485            return;
486        }
487        if resources
488            .get::<BatchResources>()
489            .is_some_and(|all| all.batches.contains_key(self.id.as_str()))
490        {
491            log::warn!(
492                "sprite_batch[{}]: rebuilding pipeline/texture (format/sample/desc changed); atlas contents dropped, re-upload required",
493                self.id
494            );
495        }
496        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
497            label: Some("sprite_batch"),
498            source: wgpu::ShaderSource::Wgsl(include_str!("../shaders/sprite.wgsl").into()),
499        });
500        let corners = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
501            label: Some("sprite_batch_corners"),
502            contents: bytemuck::cast_slice(CORNERS),
503            usage: wgpu::BufferUsages::VERTEX,
504        });
505        let camera = device.create_buffer(&wgpu::BufferDescriptor {
506            label: Some("sprite_batch_camera"),
507            size: 64,
508            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
509            mapped_at_creation: false,
510        });
511        let instances = device.create_buffer(&wgpu::BufferDescriptor {
512            label: Some("sprite_batch_instances"),
513            size: (self.instances.len().max(1) * size_of::<BatchInstance>()) as u64,
514            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
515            mapped_at_creation: false,
516        });
517        let texture = device.create_texture(&wgpu::TextureDescriptor {
518            label: Some("sprite_batch_atlas"),
519            size: wgpu::Extent3d {
520                width: self.desc.layer_size,
521                height: self.desc.layer_size,
522                depth_or_array_layers: self.desc.layers,
523            },
524            mip_level_count: 1,
525            sample_count: 1,
526            dimension: wgpu::TextureDimension::D2,
527            format: wgpu::TextureFormat::Rgba8UnormSrgb,
528            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
529            view_formats: &[],
530        });
531        let view = texture.create_view(&wgpu::TextureViewDescriptor {
532            dimension: Some(wgpu::TextureViewDimension::D2Array),
533            ..Default::default()
534        });
535        let sampler = device.create_sampler(&sampler_desc(self.desc.filter));
536        let cam_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
537            label: Some("sprite_batch_cam_bgl"),
538            entries: &[wgpu::BindGroupLayoutEntry {
539                binding: 0,
540                visibility: wgpu::ShaderStages::VERTEX,
541                ty: wgpu::BindingType::Buffer {
542                    ty: wgpu::BufferBindingType::Uniform,
543                    has_dynamic_offset: false,
544                    min_binding_size: None,
545                },
546                count: None,
547            }],
548        });
549        let tex_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
550            label: Some("sprite_batch_tex_bgl"),
551            entries: &[
552                wgpu::BindGroupLayoutEntry {
553                    binding: 0,
554                    visibility: wgpu::ShaderStages::FRAGMENT,
555                    ty: wgpu::BindingType::Texture {
556                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
557                        view_dimension: wgpu::TextureViewDimension::D2Array,
558                        multisampled: false,
559                    },
560                    count: None,
561                },
562                wgpu::BindGroupLayoutEntry {
563                    binding: 1,
564                    visibility: wgpu::ShaderStages::FRAGMENT,
565                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
566                    count: None,
567                },
568            ],
569        });
570        let cam_bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
571            label: Some("sprite_batch_cam_bg"),
572            layout: &cam_layout,
573            entries: &[wgpu::BindGroupEntry {
574                binding: 0,
575                resource: camera.as_entire_binding(),
576            }],
577        });
578        let tex_bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
579            label: Some("sprite_batch_tex_bg"),
580            layout: &tex_layout,
581            entries: &[
582                wgpu::BindGroupEntry {
583                    binding: 0,
584                    resource: wgpu::BindingResource::TextureView(&view),
585                },
586                wgpu::BindGroupEntry {
587                    binding: 1,
588                    resource: wgpu::BindingResource::Sampler(&sampler),
589                },
590            ],
591        });
592        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
593            label: Some("sprite_batch_pl"),
594            bind_group_layouts: &[Some(&cam_layout), Some(&tex_layout)],
595            immediate_size: 0,
596        });
597        let vertex_buffers = [
598            Some(wgpu::VertexBufferLayout {
599                array_stride: 8,
600                step_mode: wgpu::VertexStepMode::Vertex,
601                attributes: &[wgpu::VertexAttribute {
602                    format: wgpu::VertexFormat::Float32x2,
603                    offset: 0,
604                    shader_location: 0,
605                }],
606            }),
607            Some(wgpu::VertexBufferLayout {
608                array_stride: size_of::<BatchInstance>() as u64,
609                step_mode: wgpu::VertexStepMode::Instance,
610                attributes: &[
611                    wgpu::VertexAttribute {
612                        format: wgpu::VertexFormat::Float32x4,
613                        offset: 0,
614                        shader_location: 2,
615                    },
616                    wgpu::VertexAttribute {
617                        format: wgpu::VertexFormat::Float32x4,
618                        offset: 16,
619                        shader_location: 3,
620                    },
621                    wgpu::VertexAttribute {
622                        format: wgpu::VertexFormat::Float32x2,
623                        offset: 32,
624                        shader_location: 4,
625                    },
626                    wgpu::VertexAttribute {
627                        format: wgpu::VertexFormat::Float32x2,
628                        offset: 40,
629                        shader_location: 5,
630                    },
631                    wgpu::VertexAttribute {
632                        format: wgpu::VertexFormat::Float32x4,
633                        offset: 48,
634                        shader_location: 6,
635                    },
636                    wgpu::VertexAttribute {
637                        format: wgpu::VertexFormat::Float32,
638                        offset: 64,
639                        shader_location: 7,
640                    },
641                    wgpu::VertexAttribute {
642                        format: wgpu::VertexFormat::Float32,
643                        offset: 68,
644                        shader_location: 8,
645                    },
646                    wgpu::VertexAttribute {
647                        format: wgpu::VertexFormat::Uint32,
648                        offset: 72,
649                        shader_location: 9,
650                    },
651                ],
652            }),
653        ];
654        let mk_pipeline = |label: &'static str, blend: wgpu::BlendState| {
655            device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
656                label: Some(label),
657                layout: Some(&pipeline_layout),
658                vertex: wgpu::VertexState {
659                    module: &shader,
660                    entry_point: Some("vs_main"),
661                    buffers: &vertex_buffers,
662                    compilation_options: Default::default(),
663                },
664                fragment: Some(wgpu::FragmentState {
665                    module: &shader,
666                    entry_point: Some("fs_main"),
667                    targets: &[Some(wgpu::ColorTargetState {
668                        format: screen.target_format,
669                        blend: Some(blend),
670                        write_mask: wgpu::ColorWrites::ALL,
671                    })],
672                    compilation_options: Default::default(),
673                }),
674                primitive: wgpu::PrimitiveState {
675                    topology: wgpu::PrimitiveTopology::TriangleList,
676                    ..Default::default()
677                },
678                depth_stencil: Some(wgpu::DepthStencilState {
679                    format: wgpu::TextureFormat::Depth24PlusStencil8,
680                    depth_write_enabled: Some(false),
681                    depth_compare: Some(wgpu::CompareFunction::Always),
682                    stencil: wgpu::StencilState {
683                        front: wgpu::StencilFaceState {
684                            compare: wgpu::CompareFunction::LessEqual,
685                            ..Default::default()
686                        },
687                        back: wgpu::StencilFaceState {
688                            compare: wgpu::CompareFunction::LessEqual,
689                            ..Default::default()
690                        },
691                        ..Default::default()
692                    },
693                    bias: wgpu::DepthBiasState::default(),
694                }),
695                multisample: wgpu::MultisampleState {
696                    count: screen.sample_count,
697                    mask: !0,
698                    alpha_to_coverage_enabled: false,
699                },
700                multiview_mask: None,
701                cache: None,
702            })
703        };
704        let pipeline_alpha = mk_pipeline("sprite_batch_pipeline", wgpu::BlendState::ALPHA_BLENDING);
705        let pipeline_multiply = mk_pipeline(
706            "sprite_batch_pipeline_mul",
707            wgpu::BlendState {
708                color: wgpu::BlendComponent {
709                    src_factor: wgpu::BlendFactor::Dst,
710                    dst_factor: wgpu::BlendFactor::Zero,
711                    operation: wgpu::BlendOperation::Add,
712                },
713                alpha: wgpu::BlendComponent::OVER,
714            },
715        );
716        let pipeline_additive = mk_pipeline(
717            "sprite_batch_pipeline_add",
718            wgpu::BlendState {
719                color: wgpu::BlendComponent {
720                    src_factor: wgpu::BlendFactor::SrcAlpha,
721                    dst_factor: wgpu::BlendFactor::One,
722                    operation: wgpu::BlendOperation::Add,
723                },
724                alpha: wgpu::BlendComponent {
725                    src_factor: wgpu::BlendFactor::One,
726                    dst_factor: wgpu::BlendFactor::One,
727                    operation: wgpu::BlendOperation::Add,
728                },
729            },
730        );
731        let entry = BatchEntry {
732            key,
733            pipeline_alpha,
734            pipeline_multiply,
735            pipeline_additive,
736            corners,
737            instances,
738            instance_cap: self.instances.len().max(1),
739            last_alpha: 0,
740            last_multiply_end: 0,
741            last_total: 0,
742            camera,
743            cam_bind,
744            tex_bind,
745            texture,
746        };
747        match resources.get_mut::<BatchResources>() {
748            Some(all) => {
749                all.batches.insert(self.id.clone(), entry);
750            }
751            None => {
752                let mut all = BatchResources {
753                    batches: HashMap::new(),
754                };
755                all.batches.insert(self.id.clone(), entry);
756                resources.insert(all);
757            }
758        }
759    }
760
761    /// Record this batch's instance counts after uploading. Split out so
762    /// sibling payloads (e.g. viewport views rebuilding the batch per
763    /// frame) can share one prepared pipeline.
764    fn finish_prepare(
765        &self,
766        alpha: u32,
767        multiply_end: u32,
768        total: u32,
769        resources: &mut CallbackResources,
770    ) {
771        if let Some(all) = resources.get_mut::<BatchResources>()
772            && let Some(res) = all.batches.get_mut(self.id.as_str())
773        {
774            res.last_alpha = alpha;
775            res.last_multiply_end = multiply_end;
776            res.last_total = total;
777        }
778    }
779
780    fn blend_group(flags: u32) -> u32 {
781        match flags {
782            1 => 2, // additive last
783            2 => 1, // multiply middle
784            _ => 0, // alpha first
785        }
786    }
787
788    /// Painter's order within one blend range: `z` ascending, with the
789    /// push index breaking ties (stable for the producer's draw order).
790    /// Across ranges the blend group dominates: all alpha draws before any
791    /// multiply, all multiply before any additive, regardless of `z` or
792    /// push order.
793    /// `page` must NEVER participate: atlas page is a packing accident,
794    /// and sorting on it reorders same-z sprites (bandit guns under
795    /// bodies, bullet layers misaligned, camp floors over the vortex).
796    fn sorted_instances(&self) -> (Vec<BatchInstance>, u32, u32) {
797        let mut v: Vec<(usize, BatchInstance)> =
798            self.instances.iter().copied().enumerate().collect();
799        v.sort_by(|(ia, a), (ib, b)| {
800            Self::blend_group(a.flags)
801                .cmp(&Self::blend_group(b.flags))
802                .then_with(|| a.z.total_cmp(&b.z).then_with(|| ia.cmp(ib)))
803        });
804        let v: Vec<BatchInstance> = v.into_iter().map(|(_, inst)| inst).collect();
805        let alpha = v
806            .iter()
807            .take_while(|i| Self::blend_group(i.flags) == 0)
808            .count() as u32;
809        let multiply_end = alpha
810            + v[alpha as usize..]
811                .iter()
812                .take_while(|i| Self::blend_group(i.flags) == 1)
813                .count() as u32;
814        (v, alpha, multiply_end)
815    }
816}
817
818/// Draw the prepared batch for one id. Shared by [`SpriteBatch`] and
819/// viewport payloads so all GPU consumers issue identical draw calls.
820pub fn draw_batch_with_id(
821    id: &str,
822    rpass: &mut wgpu::RenderPass<'_>,
823    resources: &CallbackResources,
824) {
825    let Some(all) = resources.get::<BatchResources>() else {
826        return;
827    };
828    let Some(res) = all.batches.get(id) else {
829        return;
830    };
831    if res.last_total == 0 {
832        return;
833    }
834    rpass.set_bind_group(0, &res.cam_bind, &[]);
835    rpass.set_bind_group(1, &res.tex_bind, &[]);
836    rpass.set_vertex_buffer(0, res.corners.slice(..));
837    rpass.set_vertex_buffer(1, res.instances.slice(..));
838    if res.last_alpha > 0 {
839        rpass.set_pipeline(&res.pipeline_alpha);
840        rpass.draw(0..6, 0..res.last_alpha);
841    }
842    if res.last_multiply_end > res.last_alpha {
843        rpass.set_pipeline(&res.pipeline_multiply);
844        rpass.draw(0..6, res.last_alpha..res.last_multiply_end);
845    }
846    if res.last_total > res.last_multiply_end {
847        rpass.set_pipeline(&res.pipeline_additive);
848        rpass.draw(0..6, res.last_multiply_end..res.last_total);
849    }
850}
851
852/// Draw the default batch (backward compat for single-batch apps).
853pub fn draw_batch(rpass: &mut wgpu::RenderPass<'_>, resources: &CallbackResources) {
854    draw_batch_with_id("sprite_batch.default", rpass, resources);
855}
856
857impl WgpuCallback for SpriteBatch {
858    fn prepare(
859        &self,
860        device: &wgpu::Device,
861        queue: &wgpu::Queue,
862        _encoder: &mut wgpu::CommandEncoder,
863        screen: &ScreenDescriptor,
864        resources: &mut CallbackResources,
865    ) -> Vec<wgpu::CommandBuffer> {
866        self.ensure_resources(device, screen, resources);
867        let (sorted, alpha, multiply_end) = self.sorted_instances();
868        let total = sorted.len() as u32;
869        let Some(all) = resources.get_mut::<BatchResources>() else {
870            return Vec::new();
871        };
872        let Some(res) = all.batches.get_mut(self.id.as_str()) else {
873            return Vec::new();
874        };
875
876        if sorted.len() > res.instance_cap {
877            let new_cap = sorted.len().next_power_of_two().max(64);
878            res.instances = device.create_buffer(&wgpu::BufferDescriptor {
879                label: Some("sprite_batch_instances"),
880                size: (new_cap * size_of::<BatchInstance>()) as u64,
881                usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
882                mapped_at_creation: false,
883            });
884            res.instance_cap = new_cap;
885        }
886        queue.write_buffer(
887            &res.camera,
888            0,
889            bytemuck::cast_slice(&[CameraUniform {
890                view_proj: self.camera,
891            }]),
892        );
893        if !sorted.is_empty() {
894            queue.write_buffer(&res.instances, 0, bytemuck::cast_slice(&sorted));
895        }
896        // Apply pending atlas uploads straight into array layers.
897        for up in &self.uploads {
898            let expected = up.w as usize * up.h as usize * 4;
899            if up.page >= self.desc.layers
900                || up.x + up.w > self.desc.layer_size
901                || up.y + up.h > self.desc.layer_size
902                || up.rgba.len() != expected
903            {
904                log::warn!(
905                    "sprite_batch: dropping out-of-range upload page={} {}x{}+{}+{} ({} bytes)",
906                    up.page,
907                    up.w,
908                    up.h,
909                    up.x,
910                    up.y,
911                    up.rgba.len()
912                );
913                continue;
914            }
915            queue.write_texture(
916                wgpu::TexelCopyTextureInfo {
917                    texture: &res.texture,
918                    mip_level: 0,
919                    origin: wgpu::Origin3d {
920                        x: up.x,
921                        y: up.y,
922                        z: up.page,
923                    },
924                    aspect: wgpu::TextureAspect::All,
925                },
926                &up.rgba,
927                wgpu::TexelCopyBufferLayout {
928                    offset: 0,
929                    bytes_per_row: Some(up.w * 4),
930                    rows_per_image: Some(up.h),
931                },
932                wgpu::Extent3d {
933                    width: up.w,
934                    height: up.h,
935                    depth_or_array_layers: 1,
936                },
937            );
938        }
939        self.finish_prepare(alpha, multiply_end, total, resources);
940        Vec::new()
941    }
942
943    fn paint(
944        &self,
945        _info: repose_core::PaintCallbackInfo,
946        rpass: &mut wgpu::RenderPass<'static>,
947        resources: &CallbackResources,
948    ) {
949        draw_batch_with_id(self.id.as_str(), rpass, resources);
950    }
951}
952
953#[cfg(test)]
954mod tests {
955    use super::*;
956
957    fn apply(rows: ([f32; 4], [f32; 4]), corner: [f32; 2]) -> [f32; 2] {
958        let (r0, r1) = rows;
959        [
960            r0[0] * corner[0] + r0[1] * corner[1] + r0[3],
961            r1[0] * corner[0] + r1[1] * corner[1] + r1[3],
962        ]
963    }
964
965    #[test]
966    fn default_rows_match_canvas_top_left() {
967        let rows = instance_rows([400.0, 300.0], [64.0, 80.0], 0.0, [0.5, 0.5], false, false);
968        let tl = apply(rows, [-0.5, -0.5]);
969        let br = apply(rows, [0.5, 0.5]);
970        assert_eq!(tl, [368.0, 260.0]);
971        assert_eq!(br, [432.0, 340.0]);
972    }
973
974    #[test]
975    fn frame_uv_indexes_row_major() {
976        // 4x2 sheet: frame 5 is column 1, row 1.
977        let (mn, mx) = frame_uv(4, 2, 5);
978        assert_eq!(mn, [0.25, 0.5]);
979        assert_eq!(mx, [0.5, 1.0]);
980        // First cell starts at the origin; clamping holds the last cell.
981        assert_eq!(frame_uv(4, 2, 0).0, [0.0, 0.0]);
982        assert_eq!(frame_uv(4, 2, 99), frame_uv(4, 2, 7));
983        assert_eq!(frame_uv(0, 0, 3), ([0.0, 0.0], [1.0, 1.0]));
984    }
985
986    #[test]
987    fn sprite_aabb_matches_unrotated_quad() {
988        let (mn, mx) = sprite_aabb([400.0, 300.0], [64.0, 80.0], 0.0, [0.5, 0.5], false, false);
989        assert_eq!(mn, [368.0, 260.0]);
990        assert_eq!(mx, [432.0, 340.0]);
991        // A quarter turn swaps the footprint: 64x80 becomes 80x64.
992        let (mn, mx) = sprite_aabb(
993            [0.0, 0.0],
994            [64.0, 80.0],
995            std::f32::consts::FRAC_PI_2,
996            [0.5, 0.5],
997            false,
998            false,
999        );
1000        assert!((mn[0] + 40.0).abs() < 1e-4 && (mn[1] + 32.0).abs() < 1e-4);
1001        assert!((mx[0] - 40.0).abs() < 1e-4 && (mx[1] - 32.0).abs() < 1e-4);
1002    }
1003
1004    #[test]
1005    fn quarter_turn_rotates_footprint() {
1006        let rows = instance_rows(
1007            [0.0, 0.0],
1008            [64.0, 80.0],
1009            std::f32::consts::FRAC_PI_2,
1010            [0.5, 0.5],
1011            false,
1012            false,
1013        );
1014        // Local top-left (-32,-40) rotates to (40,-32) in y-down space.
1015        let p = apply(rows, [-0.5, -0.5]);
1016        assert!(
1017            (p[0] - 40.0).abs() < 1e-4 && (p[1] + 32.0).abs() < 1e-4,
1018            "got {p:?}"
1019        );
1020    }
1021
1022    #[test]
1023    fn top_left_anchor_pins_corner() {
1024        let rows = instance_rows([100.0, 100.0], [64.0, 80.0], 0.0, [0.0, 0.0], false, false);
1025        let tl = apply(rows, [-0.5, -0.5]);
1026        assert_eq!(tl, [100.0, 100.0]);
1027    }
1028
1029    #[test]
1030    fn flip_x_mirrors_around_anchor() {
1031        let plain = instance_rows([0.0, 0.0], [64.0, 8.0], 0.0, [0.5, 0.5], false, false);
1032        let flipped = instance_rows([0.0, 0.0], [64.0, 8.0], 0.0, [0.5, 0.5], true, false);
1033        let a = apply(plain, [-0.5, 0.0]);
1034        let b = apply(flipped, [-0.5, 0.0]);
1035        assert_eq!(a, [-32.0, 0.0]);
1036        assert_eq!(b, [32.0, 0.0]);
1037    }
1038
1039    #[test]
1040    fn screen_camera_is_y_down() {
1041        let cam = screen_camera([800.0, 600.0]);
1042        let top_left = cam.project_point3(glam::Vec3::new(0.0, 0.0, 0.0));
1043        let bottom_right = cam.project_point3(glam::Vec3::new(800.0, 600.0, 0.0));
1044        let center = cam.project_point3(glam::Vec3::new(400.0, 300.0, 0.0));
1045        // NDC y-up: screen top is +1, screen bottom is -1.
1046        assert!((top_left.x + 1.0).abs() < 1e-5 && (top_left.y - 1.0).abs() < 1e-5);
1047        assert!((bottom_right.x - 1.0).abs() < 1e-5 && (bottom_right.y + 1.0).abs() < 1e-5);
1048        assert!(center.x.abs() < 1e-5 && center.y.abs() < 1e-5);
1049    }
1050
1051    #[test]
1052    fn push_sprite_bridges_snapshot_fields() {
1053        let mut batch = SpriteBatch::new(BatchDesc::default());
1054        batch.push_sprite(&SpriteInstance {
1055            center: glam::Vec2::new(10.0, 20.0),
1056            size: glam::Vec2::new(4.0, 6.0),
1057            uv_min: glam::Vec2::new(0.25, 0.5),
1058            uv_max: glam::Vec2::new(0.5, 0.75),
1059            color: [1.0, 0.5, 0.25, 0.8],
1060            page: 3,
1061            ..Default::default()
1062        });
1063        assert_eq!(batch.len(), 1);
1064        let inst = batch.instances[0];
1065        assert_eq!(inst.uv_min, [0.25, 0.5]);
1066        assert_eq!(inst.uv_max, [0.5, 0.75]);
1067        assert_eq!(inst.tint, [1.0, 0.5, 0.25, 0.8]);
1068        assert_eq!(inst.page, 3.0);
1069        // Default anchor centers: top-left at (8, 17).
1070        let p = apply((inst.row0, inst.row1), [-0.5, -0.5]);
1071        assert_eq!(p, [8.0, 17.0]);
1072    }
1073
1074    #[test]
1075    fn z_and_blend_sort_into_ranges() {
1076        use super::super::SpriteBlend;
1077        let mut batch = SpriteBatch::with_id("test.sort", BatchDesc::default());
1078        let quad = |z: f32, blend: SpriteBlend| SpriteInstance {
1079            center: glam::Vec2::new(0.0, 0.0),
1080            size: glam::Vec2::new(1.0, 1.0),
1081            z,
1082            blend,
1083            ..Default::default()
1084        };
1085        batch.push_sprite(&quad(5.0, SpriteBlend::Alpha));
1086        batch.push_sprite(&quad(1.0, SpriteBlend::Additive));
1087        batch.push_sprite(&quad(3.0, SpriteBlend::Multiply));
1088        batch.push_sprite(&quad(2.0, SpriteBlend::Alpha));
1089        batch.push_sprite(&quad(0.0, SpriteBlend::Multiply));
1090        let (sorted, alpha, mul_end) = batch.sorted_instances();
1091        assert_eq!((alpha, mul_end, sorted.len() as u32), (2, 4, 5));
1092        assert_eq!([sorted[0].z, sorted[1].z], [2.0, 5.0]);
1093        assert_eq!([sorted[2].z, sorted[3].z], [0.0, 3.0]);
1094        assert_eq!(sorted[4].z, 1.0);
1095        assert_eq!(sorted[4].flags, 1);
1096    }
1097
1098    /// Atlas page is a packing accident, not a draw key: same-z
1099    /// same-blend sprites keep push order regardless of page (bandit
1100    /// guns over bodies, bullet layers aligned, camp floors under the
1101    /// vortex — all same-z pairs the old page tiebreak reordered).
1102    #[test]
1103    fn same_z_keeps_push_order_across_pages() {
1104        use super::super::SpriteBlend;
1105        let mut batch = SpriteBatch::with_id("test.pages", BatchDesc::default());
1106        let quad = |page: u32| SpriteInstance {
1107            center: glam::Vec2::new(0.0, 0.0),
1108            size: glam::Vec2::new(1.0, 1.0),
1109            z: 0.0,
1110            blend: SpriteBlend::Alpha,
1111            page,
1112            ..Default::default()
1113        };
1114        batch.push_sprite(&quad(1));
1115        batch.push_sprite(&quad(0));
1116        let (sorted, _, _) = batch.sorted_instances();
1117        assert_eq!([sorted[0].page, sorted[1].page], [1.0, 0.0]);
1118    }
1119
1120    #[test]
1121    fn white_upload_is_1x1_white() {
1122        let up = AtlasUpload::white(2, 5, 7);
1123        assert_eq!((up.page, up.x, up.y, up.w, up.h), (2, 5, 7, 1, 1));
1124        assert_eq!(up.rgba, vec![255, 255, 255, 255]);
1125    }
1126
1127    /// End-to-end GPU proof: upload atlas pages, draw textured quads
1128    /// offscreen, and assert exact pixels (positions, colors, page,
1129    /// mirror). sRGB primaries are transfer-function fixed points, so
1130    /// asserts are exact. Skips gracefully where no GPU exists.
1131    #[test]
1132    fn offscreen_batch_draws_oriented_textured_quads() {
1133        use repose_core::{Color, Rect, Scene, SceneNode};
1134        use repose_render_wgpu::{Callback, offscreen::OffscreenRenderer};
1135
1136        let mut renderer = match OffscreenRenderer::new_blocking(256, 256, 1) {
1137            Ok(r) => r,
1138            Err(e) => {
1139                eprintln!("SKIP offscreen batch test (no GPU): {e}");
1140                return;
1141            }
1142        };
1143        // Page 0 (2x2, row-major top first): R G / B W.
1144        let page0 = vec![
1145            255, 0, 0, 255, //
1146            0, 255, 0, 255, //
1147            0, 0, 255, 255, //
1148            255, 255, 255, 255,
1149        ];
1150        // Page 1: solid magenta.
1151        let page1 = [255, 0, 255, 255].repeat(4);
1152        let mut batch = SpriteBatch::new(BatchDesc {
1153            layer_size: 2,
1154            layers: 2,
1155            filter: TextureFilter::Nearest,
1156        });
1157        batch.upload(AtlasUpload {
1158            page: 0,
1159            x: 0,
1160            y: 0,
1161            w: 2,
1162            h: 2,
1163            rgba: page0,
1164        });
1165        batch.upload(AtlasUpload {
1166            page: 1,
1167            x: 0,
1168            y: 0,
1169            w: 2,
1170            h: 2,
1171            rgba: page1,
1172        });
1173        batch.set_camera(screen_camera([256.0, 256.0]));
1174        // Big quad over the middle sampling page 0 fully.
1175        batch.push(
1176            [128.0, 128.0],
1177            [128.0, 128.0],
1178            0.0,
1179            [0.5, 0.5],
1180            false,
1181            false,
1182            [0.0, 0.0],
1183            [1.0, 1.0],
1184            [1.0, 1.0, 1.0, 1.0],
1185            0,
1186        );
1187        // Small quad bottom-right sampling page 1.
1188        batch.push(
1189            [224.0, 224.0],
1190            [32.0, 32.0],
1191            0.0,
1192            [0.5, 0.5],
1193            false,
1194            false,
1195            [0.0, 0.0],
1196            [1.0, 1.0],
1197            [1.0, 1.0, 1.0, 1.0],
1198            1,
1199        );
1200        let scene = Scene {
1201            clear_color: Color::from_rgba(0, 0, 0, 255),
1202            nodes: vec![SceneNode::Callback {
1203                rect: Rect {
1204                    x: 0.0,
1205                    y: 0.0,
1206                    w: 256.0,
1207                    h: 256.0,
1208                },
1209                payload: Callback::new(batch),
1210            }],
1211        };
1212        let px = renderer
1213            .render_rgba(&scene, Some([0.0, 0.0, 0.0, 1.0]))
1214            .expect("offscreen render");
1215        let at = |x: u32, y: u32| -> [u8; 4] {
1216            let i = ((y * 256 + x) * 4) as usize;
1217            [px[i], px[i + 1], px[i + 2], px[i + 3]]
1218        };
1219        // Orientation: uv (0,0) is the PNG top-left.
1220        assert_eq!(at(64, 64), [255, 0, 0, 255], "top-left red");
1221        assert_eq!(at(191, 64), [0, 255, 0, 255], "top-right green");
1222        assert_eq!(at(64, 191), [0, 0, 255, 255], "bottom-left blue");
1223        assert_eq!(at(191, 191), [255, 255, 255, 255], "bottom-right white");
1224        assert_eq!(at(224, 224), [255, 0, 255, 255], "page 1 magenta");
1225        assert_eq!(at(8, 8), [0, 0, 0, 255], "background clear");
1226
1227        // Mirror check: same quad flipped samples the opposite column.
1228        let mut flipped = SpriteBatch::new(BatchDesc {
1229            layer_size: 2,
1230            layers: 2,
1231            filter: TextureFilter::Nearest,
1232        });
1233        flipped.set_camera(screen_camera([256.0, 256.0]));
1234        flipped.push(
1235            [128.0, 128.0],
1236            [128.0, 128.0],
1237            0.0,
1238            [0.5, 0.5],
1239            true,
1240            false,
1241            [0.0, 0.0],
1242            [1.0, 1.0],
1243            [1.0, 1.0, 1.0, 1.0],
1244            0,
1245        );
1246        let scene = Scene {
1247            clear_color: Color::from_rgba(0, 0, 0, 255),
1248            nodes: vec![SceneNode::Callback {
1249                rect: Rect {
1250                    x: 0.0,
1251                    y: 0.0,
1252                    w: 256.0,
1253                    h: 256.0,
1254                },
1255                payload: Callback::new(flipped),
1256            }],
1257        };
1258        let px = renderer
1259            .render_rgba(&scene, Some([0.0, 0.0, 0.0, 1.0]))
1260            .expect("offscreen render");
1261        let at = |x: u32, y: u32| -> [u8; 4] {
1262            let i = ((y * 256 + x) * 4) as usize;
1263            [px[i], px[i + 1], px[i + 2], px[i + 3]]
1264        };
1265        assert_eq!(at(64, 64), [0, 255, 0, 255], "flipped top-left green");
1266        assert_eq!(at(191, 191), [0, 0, 255, 255], "flipped bottom-right blue");
1267    }
1268}