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