Skip to main content

repame_view2d/
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_view2d::{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_view2d::{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). Re-keying the atlas (a new
269/// [`BatchDesc`] or `uploads_gen`) drops its contents; the game re-fills
270/// it with the uploads that came with the new descriptor.
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    ///
396    /// The atlas and the render target are both `Rgba8UnormSrgb`, so the
397    /// sampled texel and the written pixel are already in linear space and
398    /// the tint has to be linearized here; [`Self::push`] takes one
399    /// directly.
400    pub fn push_sprite(&mut self, s: &SpriteInstance) {
401        self.push_blended(
402            [s.center.x, s.center.y],
403            [s.size.x, s.size.y],
404            s.rotation,
405            [s.anchor.x, s.anchor.y],
406            s.flip_x,
407            s.flip_y,
408            [s.uv_min.x, s.uv_min.y],
409            [s.uv_max.x, s.uv_max.y],
410            super::rgba8(s.color).to_linear(),
411            s.page,
412            s.z,
413            s.blend,
414        );
415    }
416}
417
418#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
419struct AtlasDescriptor {
420    layer_size: u32,
421    layers: u32,
422    filter: TextureFilter,
423}
424
425#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
426struct AtlasKey {
427    descriptor: AtlasDescriptor,
428    generation: u64,
429}
430
431struct AtlasLayout {
432    tex_layout: wgpu::BindGroupLayout,
433    sampler: wgpu::Sampler,
434}
435
436struct AtlasResources {
437    texture: wgpu::Texture,
438    tex_bind: wgpu::BindGroup,
439    applied_gen: Arc<Mutex<Option<u64>>>,
440    /// Generation already reported as unfilled, so the report lands once per
441    /// generation rather than once per frame.
442    starved_gen: Arc<Mutex<Option<u64>>>,
443}
444
445struct BatchEntry {
446    key: (wgpu::TextureFormat, u32, u32, u32, TextureFilter),
447    pipeline_alpha: wgpu::RenderPipeline,
448    pipeline_multiply: wgpu::RenderPipeline,
449    pipeline_additive: wgpu::RenderPipeline,
450    corners: wgpu::Buffer,
451    instances: wgpu::Buffer,
452    instance_cap: usize,
453    /// Sorted ranges: `0..alpha` alpha, `alpha..multiply_end` multiply,
454    /// `multiply_end..total` additive.
455    last_alpha: u32,
456    last_multiply_end: u32,
457    last_total: u32,
458    camera: wgpu::Buffer,
459    cam_bind: wgpu::BindGroup,
460    atlas: Arc<AtlasResources>,
461    atlas_key: AtlasKey,
462}
463
464struct BatchResources {
465    batches: HashMap<String, BatchEntry>,
466    atlases: HashMap<AtlasKey, Arc<AtlasResources>>,
467    layouts: HashMap<AtlasDescriptor, Arc<AtlasLayout>>,
468}
469
470impl BatchResources {
471    fn prune_atlases(&mut self) {
472        let live: HashSet<AtlasKey> = self.batches.values().map(|entry| entry.atlas_key).collect();
473        self.atlases.retain(|key, _| live.contains(key));
474    }
475}
476
477const CORNERS: &[f32] = &[
478    -0.5, -0.5, //
479    0.5, -0.5, //
480    0.5, 0.5, //
481    -0.5, -0.5, //
482    0.5, 0.5, //
483    -0.5, 0.5, //
484];
485
486fn sampler_desc(filter: TextureFilter) -> wgpu::SamplerDescriptor<'static> {
487    let f = match filter {
488        TextureFilter::Nearest => wgpu::FilterMode::Nearest,
489        TextureFilter::Linear => wgpu::FilterMode::Linear,
490    };
491    let m = match filter {
492        TextureFilter::Nearest => wgpu::MipmapFilterMode::Nearest,
493        TextureFilter::Linear => wgpu::MipmapFilterMode::Linear,
494    };
495    wgpu::SamplerDescriptor {
496        label: Some("sprite_batch_sampler"),
497        address_mode_u: wgpu::AddressMode::ClampToEdge,
498        address_mode_v: wgpu::AddressMode::ClampToEdge,
499        address_mode_w: wgpu::AddressMode::ClampToEdge,
500        mag_filter: f,
501        min_filter: f,
502        mipmap_filter: m,
503        lod_min_clamp: 0.0,
504        lod_max_clamp: 1.0,
505        compare: None,
506        anisotropy_clamp: 1,
507        border_color: None,
508    }
509}
510
511/// The atlas view dimension and the shader that matches it.
512///
513/// A single-layer atlas is a plain `D2` texture. GL/ANGLE (which is what a
514/// browser actually hands wgpu) assumes `D2` for a one-layer texture and
515/// misreports a `D2Array` view of it, after which sampling returns black and
516/// every sprite disappears with no validation error anywhere. So a one-layer
517/// atlas is never made an array in the first place.
518pub(crate) fn atlas_shape(layers: u32) -> (wgpu::TextureViewDimension, bool) {
519    if layers <= 1 {
520        (wgpu::TextureViewDimension::D2, false)
521    } else {
522        (wgpu::TextureViewDimension::D2Array, true)
523    }
524}
525
526pub(crate) fn sprite_shader(layers: u32) -> &'static str {
527    if atlas_shape(layers).1 {
528        include_str!("../shaders/sprite.wgsl")
529    } else {
530        include_str!("../shaders/sprite_single.wgsl")
531    }
532}
533
534fn create_atlas_layout(device: &wgpu::Device, filter: TextureFilter, layers: u32) -> AtlasLayout {
535    let (dimension, _) = atlas_shape(layers);
536    let sampler = device.create_sampler(&sampler_desc(filter));
537    let tex_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
538        label: Some("sprite_batch_tex_bgl"),
539        entries: &[
540            wgpu::BindGroupLayoutEntry {
541                binding: 0,
542                visibility: wgpu::ShaderStages::FRAGMENT,
543                ty: wgpu::BindingType::Texture {
544                    sample_type: wgpu::TextureSampleType::Float { filterable: true },
545                    view_dimension: dimension,
546                    multisampled: false,
547                },
548                count: None,
549            },
550            wgpu::BindGroupLayoutEntry {
551                binding: 1,
552                visibility: wgpu::ShaderStages::FRAGMENT,
553                ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
554                count: None,
555            },
556        ],
557    });
558    AtlasLayout {
559        tex_layout,
560        sampler,
561    }
562}
563
564fn create_atlas(device: &wgpu::Device, key: AtlasKey, layout: &AtlasLayout) -> AtlasResources {
565    let descriptor = key.descriptor;
566    let texture = device.create_texture(&wgpu::TextureDescriptor {
567        label: Some("sprite_batch_atlas"),
568        size: wgpu::Extent3d {
569            width: descriptor.layer_size,
570            height: descriptor.layer_size,
571            depth_or_array_layers: descriptor.layers,
572        },
573        mip_level_count: 1,
574        sample_count: 1,
575        dimension: wgpu::TextureDimension::D2,
576        format: wgpu::TextureFormat::Rgba8UnormSrgb,
577        usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
578        view_formats: &[],
579    });
580    let (dimension, _) = atlas_shape(descriptor.layers);
581    let view = texture.create_view(&wgpu::TextureViewDescriptor {
582        dimension: Some(dimension),
583        ..Default::default()
584    });
585    let tex_bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
586        label: Some("sprite_batch_tex_bg"),
587        layout: &layout.tex_layout,
588        entries: &[
589            wgpu::BindGroupEntry {
590                binding: 0,
591                resource: wgpu::BindingResource::TextureView(&view),
592            },
593            wgpu::BindGroupEntry {
594                binding: 1,
595                resource: wgpu::BindingResource::Sampler(&layout.sampler),
596            },
597        ],
598    });
599    AtlasResources {
600        texture,
601        tex_bind,
602        applied_gen: Arc::new(Mutex::new(None)),
603        starved_gen: Arc::new(Mutex::new(None)),
604    }
605}
606
607impl SpriteBatch {
608    fn ensure_resources(
609        &self,
610        device: &wgpu::Device,
611        screen: &ScreenDescriptor,
612        resources: &mut CallbackResources,
613    ) {
614        let descriptor = AtlasDescriptor {
615            layer_size: self.desc.layer_size,
616            layers: self.desc.layers,
617            filter: self.desc.filter,
618        };
619        let atlas_key = AtlasKey {
620            descriptor,
621            generation: self.desc.uploads_gen,
622        };
623        let key = (
624            screen.target_format,
625            screen.sample_count,
626            self.desc.layer_size,
627            self.desc.layers,
628            self.desc.filter,
629        );
630        if resources.get::<BatchResources>().is_none() {
631            resources.insert(BatchResources {
632                batches: HashMap::new(),
633                atlases: HashMap::new(),
634                layouts: HashMap::new(),
635            });
636        }
637        let layout = resources
638            .get_mut::<BatchResources>()
639            .expect("batch resources initialized")
640            .layouts
641            .entry(descriptor)
642            .or_insert_with(|| {
643                Arc::new(create_atlas_layout(
644                    device,
645                    descriptor.filter,
646                    descriptor.layers,
647                ))
648            })
649            .clone();
650        let atlas = resources
651            .get_mut::<BatchResources>()
652            .expect("batch resources initialized")
653            .atlases
654            .entry(atlas_key)
655            .or_insert_with(|| Arc::new(create_atlas(device, atlas_key, &layout)))
656            .clone();
657        let needs = resources
658            .get::<BatchResources>()
659            .expect("batch resources initialized")
660            .batches
661            .get(self.id.as_str())
662            .is_none_or(|entry| entry.key != key);
663        if !needs {
664            if let Some(all) = resources.get_mut::<BatchResources>()
665                && let Some(entry) = all.batches.get_mut(self.id.as_str())
666            {
667                entry.atlas = atlas;
668                entry.atlas_key = atlas_key;
669                all.prune_atlases();
670            }
671            return;
672        }
673        if resources
674            .get::<BatchResources>()
675            .is_some_and(|all| all.batches.contains_key(self.id.as_str()))
676        {
677            log::warn!(
678                "sprite_batch[{}]: rebuilding pipeline for format/sample/descriptor change",
679                self.id
680            );
681        }
682        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
683            label: Some("sprite_batch"),
684            source: wgpu::ShaderSource::Wgsl(sprite_shader(self.desc.layers).into()),
685        });
686        let corners = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
687            label: Some("sprite_batch_corners"),
688            contents: bytemuck::cast_slice(CORNERS),
689            usage: wgpu::BufferUsages::VERTEX,
690        });
691        let camera = device.create_buffer(&wgpu::BufferDescriptor {
692            label: Some("sprite_batch_camera"),
693            size: 64,
694            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
695            mapped_at_creation: false,
696        });
697        let instances = device.create_buffer(&wgpu::BufferDescriptor {
698            label: Some("sprite_batch_instances"),
699            size: (self.instances.len().max(1) * size_of::<BatchInstance>()) as u64,
700            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
701            mapped_at_creation: false,
702        });
703        let cam_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
704            label: Some("sprite_batch_cam_bgl"),
705            entries: &[wgpu::BindGroupLayoutEntry {
706                binding: 0,
707                visibility: wgpu::ShaderStages::VERTEX,
708                ty: wgpu::BindingType::Buffer {
709                    ty: wgpu::BufferBindingType::Uniform,
710                    has_dynamic_offset: false,
711                    min_binding_size: None,
712                },
713                count: None,
714            }],
715        });
716        let cam_bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
717            label: Some("sprite_batch_cam_bg"),
718            layout: &cam_layout,
719            entries: &[wgpu::BindGroupEntry {
720                binding: 0,
721                resource: camera.as_entire_binding(),
722            }],
723        });
724        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
725            label: Some("sprite_batch_pl"),
726            bind_group_layouts: &[Some(&cam_layout), Some(&layout.tex_layout)],
727            immediate_size: 0,
728        });
729        let vertex_buffers = [
730            Some(wgpu::VertexBufferLayout {
731                array_stride: 8,
732                step_mode: wgpu::VertexStepMode::Vertex,
733                attributes: &[wgpu::VertexAttribute {
734                    format: wgpu::VertexFormat::Float32x2,
735                    offset: 0,
736                    shader_location: 0,
737                }],
738            }),
739            Some(wgpu::VertexBufferLayout {
740                array_stride: size_of::<BatchInstance>() as u64,
741                step_mode: wgpu::VertexStepMode::Instance,
742                attributes: &[
743                    wgpu::VertexAttribute {
744                        format: wgpu::VertexFormat::Float32x4,
745                        offset: 0,
746                        shader_location: 2,
747                    },
748                    wgpu::VertexAttribute {
749                        format: wgpu::VertexFormat::Float32x4,
750                        offset: 16,
751                        shader_location: 3,
752                    },
753                    wgpu::VertexAttribute {
754                        format: wgpu::VertexFormat::Float32x2,
755                        offset: 32,
756                        shader_location: 4,
757                    },
758                    wgpu::VertexAttribute {
759                        format: wgpu::VertexFormat::Float32x2,
760                        offset: 40,
761                        shader_location: 5,
762                    },
763                    wgpu::VertexAttribute {
764                        format: wgpu::VertexFormat::Float32x4,
765                        offset: 48,
766                        shader_location: 6,
767                    },
768                    wgpu::VertexAttribute {
769                        format: wgpu::VertexFormat::Float32,
770                        offset: 64,
771                        shader_location: 7,
772                    },
773                    wgpu::VertexAttribute {
774                        format: wgpu::VertexFormat::Float32,
775                        offset: 68,
776                        shader_location: 8,
777                    },
778                    wgpu::VertexAttribute {
779                        format: wgpu::VertexFormat::Uint32,
780                        offset: 72,
781                        shader_location: 9,
782                    },
783                    wgpu::VertexAttribute {
784                        format: wgpu::VertexFormat::Float32,
785                        offset: 76,
786                        shader_location: 10,
787                    },
788                ],
789            }),
790        ];
791        let mk_pipeline = |label: &'static str, blend: wgpu::BlendState| {
792            device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
793                label: Some(label),
794                layout: Some(&pipeline_layout),
795                vertex: wgpu::VertexState {
796                    module: &shader,
797                    entry_point: Some("vs_main"),
798                    buffers: &vertex_buffers,
799                    compilation_options: Default::default(),
800                },
801                fragment: Some(wgpu::FragmentState {
802                    module: &shader,
803                    entry_point: Some("fs_main"),
804                    targets: &[Some(wgpu::ColorTargetState {
805                        format: screen.target_format,
806                        blend: Some(blend),
807                        write_mask: wgpu::ColorWrites::ALL,
808                    })],
809                    compilation_options: Default::default(),
810                }),
811                primitive: wgpu::PrimitiveState {
812                    topology: wgpu::PrimitiveTopology::TriangleList,
813                    ..Default::default()
814                },
815                depth_stencil: Some(wgpu::DepthStencilState {
816                    format: wgpu::TextureFormat::Depth24PlusStencil8,
817                    depth_write_enabled: Some(false),
818                    depth_compare: Some(wgpu::CompareFunction::Always),
819                    stencil: wgpu::StencilState {
820                        front: wgpu::StencilFaceState {
821                            compare: wgpu::CompareFunction::LessEqual,
822                            ..Default::default()
823                        },
824                        back: wgpu::StencilFaceState {
825                            compare: wgpu::CompareFunction::LessEqual,
826                            ..Default::default()
827                        },
828                        ..Default::default()
829                    },
830                    bias: wgpu::DepthBiasState::default(),
831                }),
832                multisample: wgpu::MultisampleState {
833                    count: screen.sample_count,
834                    mask: !0,
835                    alpha_to_coverage_enabled: false,
836                },
837                multiview_mask: None,
838                cache: None,
839            })
840        };
841        let pipeline_alpha = mk_pipeline("sprite_batch_pipeline", wgpu::BlendState::ALPHA_BLENDING);
842        let pipeline_multiply = mk_pipeline(
843            "sprite_batch_pipeline_mul",
844            wgpu::BlendState {
845                color: wgpu::BlendComponent {
846                    src_factor: wgpu::BlendFactor::Dst,
847                    dst_factor: wgpu::BlendFactor::Zero,
848                    operation: wgpu::BlendOperation::Add,
849                },
850                alpha: wgpu::BlendComponent::OVER,
851            },
852        );
853        let pipeline_additive = mk_pipeline(
854            "sprite_batch_pipeline_add",
855            wgpu::BlendState {
856                color: wgpu::BlendComponent {
857                    src_factor: wgpu::BlendFactor::SrcAlpha,
858                    dst_factor: wgpu::BlendFactor::One,
859                    operation: wgpu::BlendOperation::Add,
860                },
861                alpha: wgpu::BlendComponent {
862                    src_factor: wgpu::BlendFactor::One,
863                    dst_factor: wgpu::BlendFactor::One,
864                    operation: wgpu::BlendOperation::Add,
865                },
866            },
867        );
868        let entry = BatchEntry {
869            key,
870            pipeline_alpha,
871            pipeline_multiply,
872            pipeline_additive,
873            corners,
874            instances,
875            instance_cap: self.instances.len().max(1),
876            last_alpha: 0,
877            last_multiply_end: 0,
878            last_total: 0,
879            camera,
880            cam_bind,
881            atlas,
882            atlas_key,
883        };
884        resources
885            .get_mut::<BatchResources>()
886            .expect("batch resources initialized")
887            .batches
888            .insert(self.id.clone(), entry);
889        resources
890            .get_mut::<BatchResources>()
891            .expect("batch resources initialized")
892            .prune_atlases();
893    }
894
895    /// Record this batch's instance counts after uploading. Split out so
896    /// sibling payloads (e.g. viewport views rebuilding the batch per
897    /// frame) can share one prepared pipeline.
898    fn finish_prepare(
899        &self,
900        alpha: u32,
901        multiply_end: u32,
902        total: u32,
903        resources: &mut CallbackResources,
904    ) {
905        if let Some(all) = resources.get_mut::<BatchResources>()
906            && let Some(res) = all.batches.get_mut(self.id.as_str())
907        {
908            res.last_alpha = alpha;
909            res.last_multiply_end = multiply_end;
910            res.last_total = total;
911        }
912    }
913
914    fn blend_group(flags: u32) -> u32 {
915        match flags {
916            1 => 2, // additive last
917            2 => 1, // multiply middle
918            _ => 0, // alpha first
919        }
920    }
921
922    /// Painter's order within one blend range: `z` ascending, with the
923    /// push index breaking ties (stable for the producer's draw order).
924    /// Across ranges the blend group dominates: all alpha draws before any
925    /// multiply, all multiply before any additive, regardless of `z` or
926    /// push order.
927    /// `page` must NEVER participate: atlas page is a packing accident,
928    /// and sorting on it reorders same-z sprites (bandit guns under
929    /// bodies, bullet layers misaligned, camp floors over the vortex).
930    fn sorted_instances(&self) -> (Vec<BatchInstance>, u32, u32) {
931        let mut v: Vec<(usize, BatchInstance)> =
932            self.instances.iter().copied().enumerate().collect();
933        v.sort_by(|(ia, a), (ib, b)| {
934            Self::blend_group(a.flags)
935                .cmp(&Self::blend_group(b.flags))
936                .then_with(|| a.z.total_cmp(&b.z).then_with(|| ia.cmp(ib)))
937        });
938        let v: Vec<BatchInstance> = v.into_iter().map(|(_, inst)| inst).collect();
939        let alpha = v
940            .iter()
941            .take_while(|i| Self::blend_group(i.flags) == 0)
942            .count() as u32;
943        let multiply_end = alpha
944            + v[alpha as usize..]
945                .iter()
946                .take_while(|i| Self::blend_group(i.flags) == 1)
947                .count() as u32;
948        (v, alpha, multiply_end)
949    }
950
951    /// Game-owned passes can drive this batch into their OWN
952    /// render target instead.
953    pub fn prepare_with_uploads(
954        &self,
955        device: &wgpu::Device,
956        queue: &wgpu::Queue,
957        _encoder: &mut wgpu::CommandEncoder,
958        screen: &ScreenDescriptor,
959        resources: &mut CallbackResources,
960        uploads: &[AtlasUpload],
961    ) -> Vec<wgpu::CommandBuffer> {
962        self.ensure_resources(device, screen, resources);
963        let (sorted, alpha, multiply_end) = self.sorted_instances();
964        let total = sorted.len() as u32;
965        let Some(all) = resources.get_mut::<BatchResources>() else {
966            return Vec::new();
967        };
968        let Some(res) = all.batches.get_mut(self.id.as_str()) else {
969            return Vec::new();
970        };
971
972        if sorted.len() > res.instance_cap {
973            let new_cap = sorted.len().next_power_of_two().max(64);
974            res.instances = device.create_buffer(&wgpu::BufferDescriptor {
975                label: Some("sprite_batch_instances"),
976                size: (new_cap * size_of::<BatchInstance>()) as u64,
977                usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
978                mapped_at_creation: false,
979            });
980            res.instance_cap = new_cap;
981        }
982        queue.write_buffer(
983            &res.camera,
984            0,
985            bytemuck::cast_slice(&[CameraUniform {
986                view_proj: self.camera,
987            }]),
988        );
989        if !sorted.is_empty() {
990            queue.write_buffer(&res.instances, 0, bytemuck::cast_slice(&sorted));
991        }
992        let atlas = res.atlas.clone();
993        // The atlas trails `desc.uploads_gen` until the generation's uploads
994        // have been written, and it cannot be drawn from before then.
995        let atlas_behind = atlas
996            .applied_gen
997            .lock()
998            .map(|generation| *generation != Some(self.desc.uploads_gen))
999            .unwrap_or(true);
1000        if atlas_behind && uploads.is_empty() {
1001            self.report_atlas_starved(&atlas);
1002            self.finish_prepare(alpha, multiply_end, total, resources);
1003            return Vec::new();
1004        }
1005        let mut wrote_any = false;
1006        for up in uploads {
1007            if !atlas_behind {
1008                continue;
1009            }
1010            let expected = up.w as usize * up.h as usize * 4;
1011            if up.page >= self.desc.layers
1012                || up.x + up.w > self.desc.layer_size
1013                || up.y + up.h > self.desc.layer_size
1014                || up.rgba.len() != expected
1015            {
1016                log::warn!(
1017                    "sprite_batch: dropping out-of-range upload page={} {}x{}+{}+{} ({} bytes)",
1018                    up.page,
1019                    up.w,
1020                    up.h,
1021                    up.x,
1022                    up.y,
1023                    up.rgba.len()
1024                );
1025                continue;
1026            }
1027            queue.write_texture(
1028                wgpu::TexelCopyTextureInfo {
1029                    texture: &atlas.texture,
1030                    mip_level: 0,
1031                    origin: wgpu::Origin3d {
1032                        x: up.x,
1033                        y: up.y,
1034                        z: up.page,
1035                    },
1036                    aspect: wgpu::TextureAspect::All,
1037                },
1038                &up.rgba,
1039                wgpu::TexelCopyBufferLayout {
1040                    offset: 0,
1041                    bytes_per_row: Some(up.w * 4),
1042                    rows_per_image: Some(up.h),
1043                },
1044                wgpu::Extent3d {
1045                    width: up.w,
1046                    height: up.h,
1047                    depth_or_array_layers: 1,
1048                },
1049            );
1050            wrote_any = true;
1051        }
1052        if atlas_behind
1053            && wrote_any
1054            && let Ok(mut generation) = atlas.applied_gen.lock()
1055        {
1056            *generation = Some(self.desc.uploads_gen);
1057        }
1058        self.finish_prepare(alpha, multiply_end, total, resources);
1059        Vec::new()
1060    }
1061
1062    /// The atlas trails its descriptor and nothing is queued to fill it, so the
1063    /// batch draws nothing at all.
1064    fn report_atlas_starved(&self, atlas: &AtlasResources) {
1065        let Ok(mut reported) = atlas.starved_gen.lock() else {
1066            return;
1067        };
1068        if *reported == Some(self.desc.uploads_gen) {
1069            return;
1070        }
1071        *reported = Some(self.desc.uploads_gen);
1072        log::error!(
1073            "sprite_batch[{}]: atlas generation {} is unfilled and no uploads arrived, \
1074             drawing nothing; pass that generation's uploads on every frame until \
1075             the batch applies them, because the first frame carrying them may be \
1076             built before the view is prepared and then discarded",
1077            self.id,
1078            self.desc.uploads_gen
1079        );
1080    }
1081}
1082
1083trait BatchRenderPass {
1084    fn set_bind_group(&mut self, index: u32, bind_group: &wgpu::BindGroup, offsets: &[u32]);
1085    fn set_vertex_buffer(&mut self, slot: u32, buffer: wgpu::BufferSlice<'_>);
1086    fn set_pipeline(&mut self, pipeline: &wgpu::RenderPipeline);
1087    fn draw(&mut self, vertices: std::ops::Range<u32>, instances: std::ops::Range<u32>);
1088}
1089
1090impl BatchRenderPass for wgpu::RenderPass<'_> {
1091    fn set_bind_group(&mut self, index: u32, bind_group: &wgpu::BindGroup, offsets: &[u32]) {
1092        self.set_bind_group(index, bind_group, offsets);
1093    }
1094
1095    fn set_vertex_buffer(&mut self, slot: u32, buffer: wgpu::BufferSlice<'_>) {
1096        self.set_vertex_buffer(slot, buffer);
1097    }
1098
1099    fn set_pipeline(&mut self, pipeline: &wgpu::RenderPipeline) {
1100        self.set_pipeline(pipeline);
1101    }
1102
1103    fn draw(&mut self, vertices: std::ops::Range<u32>, instances: std::ops::Range<u32>) {
1104        self.draw(vertices, instances);
1105    }
1106}
1107
1108impl BatchRenderPass for CallbackRenderPass<'_, '_> {
1109    fn set_bind_group(&mut self, index: u32, bind_group: &wgpu::BindGroup, offsets: &[u32]) {
1110        self.set_bind_group(index, bind_group, offsets);
1111    }
1112
1113    fn set_vertex_buffer(&mut self, slot: u32, buffer: wgpu::BufferSlice<'_>) {
1114        self.set_vertex_buffer(slot, buffer);
1115    }
1116
1117    fn set_pipeline(&mut self, pipeline: &wgpu::RenderPipeline) {
1118        self.set_pipeline(pipeline);
1119    }
1120
1121    fn draw(&mut self, vertices: std::ops::Range<u32>, instances: std::ops::Range<u32>) {
1122        self.draw(vertices, instances);
1123    }
1124}
1125
1126fn draw_batch_with_pass<P: BatchRenderPass>(
1127    id: &str,
1128    rpass: &mut P,
1129    resources: &CallbackResources,
1130) {
1131    let Some(all) = resources.get::<BatchResources>() else {
1132        return;
1133    };
1134    let Some(res) = all.batches.get(id) else {
1135        return;
1136    };
1137    if res.last_total == 0 {
1138        return;
1139    }
1140    rpass.set_bind_group(0, &res.cam_bind, &[]);
1141    rpass.set_bind_group(1, &res.atlas.tex_bind, &[]);
1142    rpass.set_vertex_buffer(0, res.corners.slice(..));
1143    rpass.set_vertex_buffer(1, res.instances.slice(..));
1144    if res.last_alpha > 0 {
1145        rpass.set_pipeline(&res.pipeline_alpha);
1146        rpass.draw(0..6, 0..res.last_alpha);
1147    }
1148    if res.last_multiply_end > res.last_alpha {
1149        rpass.set_pipeline(&res.pipeline_multiply);
1150        rpass.draw(0..6, res.last_alpha..res.last_multiply_end);
1151    }
1152    if res.last_total > res.last_multiply_end {
1153        rpass.set_pipeline(&res.pipeline_additive);
1154        rpass.draw(0..6, res.last_multiply_end..res.last_total);
1155    }
1156}
1157
1158/// Draw the prepared batch for one id. Shared by [`SpriteBatch`] and
1159/// viewport payloads so all GPU consumers issue identical draw calls.
1160pub fn draw_batch_with_id(
1161    id: &str,
1162    rpass: &mut wgpu::RenderPass<'_>,
1163    resources: &CallbackResources,
1164) {
1165    draw_batch_with_pass(id, rpass, resources);
1166}
1167
1168pub(crate) fn draw_batch_with_id_callback(
1169    id: &str,
1170    rpass: &mut CallbackRenderPass<'_, '_>,
1171    resources: &CallbackResources,
1172) {
1173    draw_batch_with_pass(id, rpass, resources);
1174}
1175
1176/// Draw the default batch (backward compat for single-batch apps).
1177pub fn draw_batch(rpass: &mut wgpu::RenderPass<'_>, resources: &CallbackResources) {
1178    draw_batch_with_id("sprite_batch.default", rpass, resources);
1179}
1180
1181impl WgpuCallback for SpriteBatch {
1182    fn resource_key(&self) -> Option<&str> {
1183        Some("repame.sprite_batch.resources")
1184    }
1185    fn prepare(
1186        &self,
1187        device: &wgpu::Device,
1188        queue: &wgpu::Queue,
1189        encoder: &mut wgpu::CommandEncoder,
1190        screen: &ScreenDescriptor,
1191        resources: &mut CallbackResources,
1192    ) -> Vec<wgpu::CommandBuffer> {
1193        self.prepare_with_uploads(device, queue, encoder, screen, resources, &self.uploads)
1194    }
1195
1196    fn paint(
1197        &self,
1198        _info: repose_core::PaintCallbackInfo,
1199        rpass: &mut CallbackRenderPass<'_, '_>,
1200        resources: &CallbackResources,
1201    ) {
1202        draw_batch_with_id_callback(self.id.as_str(), rpass, resources);
1203    }
1204}
1205
1206/// One view's worth of atlas draws: the sprite rows for this frame plus the
1207/// shared atlas uploads. Each scene node owns its frame, so several views can
1208/// batch from one atlas without sharing (and clobbering) one batch; the pixel
1209/// data itself rides an `Arc` and is never cloned.
1210pub struct BatchFrame {
1211    batch: SpriteBatch,
1212    uploads: std::sync::Arc<[AtlasUpload]>,
1213}
1214
1215impl BatchFrame {
1216    pub fn new(batch: SpriteBatch, uploads: std::sync::Arc<[AtlasUpload]>) -> Self {
1217        Self { batch, uploads }
1218    }
1219
1220    pub fn node(self, rect: repose_core::Rect) -> repose_core::SceneNode {
1221        repose_core::SceneNode::Callback {
1222            rect,
1223            payload: repose_render_wgpu::Callback::new(self),
1224        }
1225    }
1226}
1227
1228impl WgpuCallback for BatchFrame {
1229    fn resource_key(&self) -> Option<&str> {
1230        Some("repame.sprite_batch.resources")
1231    }
1232
1233    fn prepare(
1234        &self,
1235        device: &wgpu::Device,
1236        queue: &wgpu::Queue,
1237        encoder: &mut wgpu::CommandEncoder,
1238        screen: &ScreenDescriptor,
1239        resources: &mut CallbackResources,
1240    ) -> Vec<wgpu::CommandBuffer> {
1241        self.batch
1242            .prepare_with_uploads(device, queue, encoder, screen, resources, &self.uploads)
1243    }
1244
1245    fn paint(
1246        &self,
1247        _info: repose_core::PaintCallbackInfo,
1248        rpass: &mut CallbackRenderPass<'_, '_>,
1249        resources: &CallbackResources,
1250    ) {
1251        draw_batch_with_id_callback(&self.batch.id, rpass, resources);
1252    }
1253}
1254
1255#[cfg(test)]
1256mod tests {
1257    use super::*;
1258
1259    fn apply(rows: ([f32; 4], [f32; 4]), corner: [f32; 2]) -> [f32; 2] {
1260        let (r0, r1) = rows;
1261        [
1262            r0[0] * corner[0] + r0[1] * corner[1] + r0[3],
1263            r1[0] * corner[0] + r1[1] * corner[1] + r1[3],
1264        ]
1265    }
1266
1267    #[test]
1268    fn default_rows_match_canvas_top_left() {
1269        let rows = instance_rows([400.0, 300.0], [64.0, 80.0], 0.0, [0.5, 0.5], false, false);
1270        let tl = apply(rows, [-0.5, -0.5]);
1271        let br = apply(rows, [0.5, 0.5]);
1272        assert_eq!(tl, [368.0, 260.0]);
1273        assert_eq!(br, [432.0, 340.0]);
1274    }
1275
1276    #[test]
1277    fn frame_uv_indexes_row_major() {
1278        // 4x2 sheet: frame 5 is column 1, row 1.
1279        let (mn, mx) = frame_uv(4, 2, 5);
1280        assert_eq!(mn, [0.25, 0.5]);
1281        assert_eq!(mx, [0.5, 1.0]);
1282        // First cell starts at the origin; clamping holds the last cell.
1283        assert_eq!(frame_uv(4, 2, 0).0, [0.0, 0.0]);
1284        assert_eq!(frame_uv(4, 2, 99), frame_uv(4, 2, 7));
1285        assert_eq!(frame_uv(0, 0, 3), ([0.0, 0.0], [1.0, 1.0]));
1286    }
1287
1288    #[test]
1289    fn sprite_aabb_matches_unrotated_quad() {
1290        let (mn, mx) = sprite_aabb([400.0, 300.0], [64.0, 80.0], 0.0, [0.5, 0.5], false, false);
1291        assert_eq!(mn, [368.0, 260.0]);
1292        assert_eq!(mx, [432.0, 340.0]);
1293        // A quarter turn swaps the footprint: 64x80 becomes 80x64.
1294        let (mn, mx) = sprite_aabb(
1295            [0.0, 0.0],
1296            [64.0, 80.0],
1297            std::f32::consts::FRAC_PI_2,
1298            [0.5, 0.5],
1299            false,
1300            false,
1301        );
1302        assert!((mn[0] + 40.0).abs() < 1e-4 && (mn[1] + 32.0).abs() < 1e-4);
1303        assert!((mx[0] - 40.0).abs() < 1e-4 && (mx[1] - 32.0).abs() < 1e-4);
1304    }
1305
1306    #[test]
1307    fn quarter_turn_rotates_footprint() {
1308        let rows = instance_rows(
1309            [0.0, 0.0],
1310            [64.0, 80.0],
1311            std::f32::consts::FRAC_PI_2,
1312            [0.5, 0.5],
1313            false,
1314            false,
1315        );
1316        // Local top-left (-32,-40) rotates to (40,-32) in y-down space.
1317        let p = apply(rows, [-0.5, -0.5]);
1318        assert!(
1319            (p[0] - 40.0).abs() < 1e-4 && (p[1] + 32.0).abs() < 1e-4,
1320            "got {p:?}"
1321        );
1322    }
1323
1324    #[test]
1325    fn top_left_anchor_pins_corner() {
1326        let rows = instance_rows([100.0, 100.0], [64.0, 80.0], 0.0, [0.0, 0.0], false, false);
1327        let tl = apply(rows, [-0.5, -0.5]);
1328        assert_eq!(tl, [100.0, 100.0]);
1329    }
1330
1331    #[test]
1332    fn flip_x_mirrors_around_anchor() {
1333        let plain = instance_rows([0.0, 0.0], [64.0, 8.0], 0.0, [0.5, 0.5], false, false);
1334        let flipped = instance_rows([0.0, 0.0], [64.0, 8.0], 0.0, [0.5, 0.5], true, false);
1335        let a = apply(plain, [-0.5, 0.0]);
1336        let b = apply(flipped, [-0.5, 0.0]);
1337        assert_eq!(a, [-32.0, 0.0]);
1338        assert_eq!(b, [32.0, 0.0]);
1339    }
1340
1341    #[test]
1342    fn screen_camera_is_y_down() {
1343        let cam = screen_camera([800.0, 600.0]);
1344        let top_left = cam.project_point3(glam::Vec3::new(0.0, 0.0, 0.0));
1345        let bottom_right = cam.project_point3(glam::Vec3::new(800.0, 600.0, 0.0));
1346        let center = cam.project_point3(glam::Vec3::new(400.0, 300.0, 0.0));
1347        // NDC y-up: screen top is +1, screen bottom is -1.
1348        assert!((top_left.x + 1.0).abs() < 1e-5 && (top_left.y - 1.0).abs() < 1e-5);
1349        assert!((bottom_right.x - 1.0).abs() < 1e-5 && (bottom_right.y + 1.0).abs() < 1e-5);
1350        assert!(center.x.abs() < 1e-5 && center.y.abs() < 1e-5);
1351    }
1352
1353    #[test]
1354    fn push_sprite_bridges_snapshot_fields() {
1355        let mut batch = SpriteBatch::new(BatchDesc::default());
1356        batch.push_sprite(&SpriteInstance {
1357            center: glam::Vec2::new(10.0, 20.0),
1358            size: glam::Vec2::new(4.0, 6.0),
1359            uv_min: glam::Vec2::new(0.25, 0.5),
1360            uv_max: glam::Vec2::new(0.5, 0.75),
1361            color: [1.0, 0.5, 0.25, 0.8],
1362            page: 3,
1363            ..Default::default()
1364        });
1365        assert_eq!(batch.len(), 1);
1366        let inst = batch.instances[0];
1367        assert_eq!(inst.uv_min, [0.25, 0.5]);
1368        assert_eq!(inst.uv_max, [0.5, 0.75]);
1369        // The snapshot tint is display-referred; the batch linearizes it.
1370        assert_eq!(
1371            inst.tint,
1372            crate::rgba8([1.0, 0.5, 0.25, 0.8]).to_linear(),
1373            "tint reaches the batch in linear space"
1374        );
1375        assert_eq!(inst.page, 3.0);
1376        // Default anchor centers: top-left at (8, 17).
1377        let p = apply((inst.row0, inst.row1), [-0.5, -0.5]);
1378        assert_eq!(p, [8.0, 17.0]);
1379    }
1380
1381    #[test]
1382    fn z_and_blend_sort_into_ranges() {
1383        use super::super::SpriteBlend;
1384        let mut batch = SpriteBatch::with_id("test.sort", BatchDesc::default());
1385        let quad = |z: f32, blend: SpriteBlend| SpriteInstance {
1386            center: glam::Vec2::new(0.0, 0.0),
1387            size: glam::Vec2::new(1.0, 1.0),
1388            z,
1389            blend,
1390            ..Default::default()
1391        };
1392        batch.push_sprite(&quad(5.0, SpriteBlend::Alpha));
1393        batch.push_sprite(&quad(1.0, SpriteBlend::Additive));
1394        batch.push_sprite(&quad(3.0, SpriteBlend::Multiply));
1395        batch.push_sprite(&quad(2.0, SpriteBlend::Alpha));
1396        batch.push_sprite(&quad(0.0, SpriteBlend::Multiply));
1397        let (sorted, alpha, mul_end) = batch.sorted_instances();
1398        assert_eq!((alpha, mul_end, sorted.len() as u32), (2, 4, 5));
1399        assert_eq!([sorted[0].z, sorted[1].z], [2.0, 5.0]);
1400        assert_eq!([sorted[2].z, sorted[3].z], [0.0, 3.0]);
1401        assert_eq!(sorted[4].z, 1.0);
1402        assert_eq!(sorted[4].flags, 1);
1403    }
1404
1405    /// Atlas page is a packing accident, not a draw key: same-z
1406    /// same-blend sprites keep push order regardless of page (bandit
1407    /// guns over bodies, bullet layers aligned, camp floors under the
1408    /// vortex — all same-z pairs the old page tiebreak reordered).
1409    #[test]
1410    fn same_z_keeps_push_order_across_pages() {
1411        use super::super::SpriteBlend;
1412        let mut batch = SpriteBatch::with_id("test.pages", BatchDesc::default());
1413        let quad = |page: u32| SpriteInstance {
1414            center: glam::Vec2::new(0.0, 0.0),
1415            size: glam::Vec2::new(1.0, 1.0),
1416            z: 0.0,
1417            blend: SpriteBlend::Alpha,
1418            page,
1419            ..Default::default()
1420        };
1421        batch.push_sprite(&quad(1));
1422        batch.push_sprite(&quad(0));
1423        let (sorted, _, _) = batch.sorted_instances();
1424        assert_eq!([sorted[0].page, sorted[1].page], [1.0, 0.0]);
1425    }
1426
1427    #[test]
1428    fn white_upload_is_1x1_white() {
1429        let up = AtlasUpload::white(2, 5, 7);
1430        assert_eq!((up.page, up.x, up.y, up.w, up.h), (2, 5, 7, 1, 1));
1431        assert_eq!(up.rgba, vec![255, 255, 255, 255]);
1432    }
1433
1434    #[test]
1435    fn single_layer_atlas_draws_tinted_sprites() {
1436        // A one-layer atlas must be a plain D2 texture, not a one-element
1437        // D2Array: GL/ANGLE assumes D2 for it and then samples the array view
1438        // as black, which silently erases every sprite with no validation
1439        // error. Guards the shape choice for the layout, the view and the
1440        // shader together, since they only agree as a set.
1441        assert_eq!(
1442            crate::batch::atlas_shape(1),
1443            (wgpu::TextureViewDimension::D2, false)
1444        );
1445        assert_eq!(
1446            crate::batch::atlas_shape(4),
1447            (wgpu::TextureViewDimension::D2Array, true)
1448        );
1449        assert!(
1450            crate::batch::sprite_shader(1).contains("texture_2d<")
1451                && !crate::batch::sprite_shader(1).contains("texture_2d_array"),
1452            "single-layer shader samples a 2d atlas"
1453        );
1454        assert!(
1455            crate::batch::sprite_shader(4).contains("texture_2d_array"),
1456            "multi-layer shader samples an array atlas"
1457        );
1458    }
1459
1460    /// End-to-end GPU proof: upload atlas pages, draw textured quads
1461    /// offscreen, and assert exact pixels (positions, colors, page,
1462    /// mirror). sRGB primaries are transfer-function fixed points, so
1463    /// asserts are exact. Skips gracefully where no GPU exists.
1464    #[test]
1465    fn offscreen_batch_draws_oriented_textured_quads() {
1466        use repose_core::{Color, Rect, Scene, SceneNode};
1467        use repose_render_wgpu::{Callback, offscreen::OffscreenRenderer};
1468
1469        let mut renderer = match OffscreenRenderer::new_blocking(256, 256, 1) {
1470            Ok(r) => r,
1471            Err(e) => {
1472                eprintln!("SKIP offscreen batch test (no GPU): {e}");
1473                return;
1474            }
1475        };
1476        // Page 0 (2x2, row-major top first): R G / B W.
1477        let page0 = vec![
1478            255, 0, 0, 255, //
1479            0, 255, 0, 255, //
1480            0, 0, 255, 255, //
1481            255, 255, 255, 255,
1482        ];
1483        // Page 1: solid magenta.
1484        let page1 = [255, 0, 255, 255].repeat(4);
1485        let mut batch = SpriteBatch::new(BatchDesc {
1486            layer_size: 2,
1487            layers: 2,
1488            filter: TextureFilter::Nearest,
1489            ..Default::default()
1490        });
1491        batch.upload(AtlasUpload {
1492            page: 0,
1493            x: 0,
1494            y: 0,
1495            w: 2,
1496            h: 2,
1497            rgba: page0,
1498        });
1499        batch.upload(AtlasUpload {
1500            page: 1,
1501            x: 0,
1502            y: 0,
1503            w: 2,
1504            h: 2,
1505            rgba: page1,
1506        });
1507        batch.set_camera(screen_camera([256.0, 256.0]));
1508        // Big quad over the middle sampling page 0 fully.
1509        batch.push(
1510            [128.0, 128.0],
1511            [128.0, 128.0],
1512            0.0,
1513            [0.5, 0.5],
1514            false,
1515            false,
1516            [0.0, 0.0],
1517            [1.0, 1.0],
1518            [1.0, 1.0, 1.0, 1.0],
1519            0,
1520        );
1521        // Small quad bottom-right sampling page 1.
1522        batch.push(
1523            [224.0, 224.0],
1524            [32.0, 32.0],
1525            0.0,
1526            [0.5, 0.5],
1527            false,
1528            false,
1529            [0.0, 0.0],
1530            [1.0, 1.0],
1531            [1.0, 1.0, 1.0, 1.0],
1532            1,
1533        );
1534        let scene = Scene {
1535            clear_color: Color::from_rgba(0, 0, 0, 255),
1536            nodes: vec![SceneNode::Callback {
1537                rect: Rect {
1538                    x: 0.0,
1539                    y: 0.0,
1540                    w: 256.0,
1541                    h: 256.0,
1542                },
1543                payload: Callback::new(batch),
1544            }],
1545        };
1546        let px = renderer
1547            .render_rgba(&scene, Some([0.0, 0.0, 0.0, 1.0]))
1548            .expect("offscreen render");
1549        let at = |x: u32, y: u32| -> [u8; 4] {
1550            let i = ((y * 256 + x) * 4) as usize;
1551            [px[i], px[i + 1], px[i + 2], px[i + 3]]
1552        };
1553        // Orientation: uv (0,0) is the PNG top-left.
1554        assert_eq!(at(64, 64), [255, 0, 0, 255], "top-left red");
1555        assert_eq!(at(191, 64), [0, 255, 0, 255], "top-right green");
1556        assert_eq!(at(64, 191), [0, 0, 255, 255], "bottom-left blue");
1557        assert_eq!(at(191, 191), [255, 255, 255, 255], "bottom-right white");
1558        assert_eq!(at(224, 224), [255, 0, 255, 255], "page 1 magenta");
1559        assert_eq!(at(8, 8), [0, 0, 0, 255], "background clear");
1560
1561        // Mirror check: same quad flipped samples the opposite column.
1562        let mut flipped = SpriteBatch::new(BatchDesc {
1563            layer_size: 2,
1564            layers: 2,
1565            filter: TextureFilter::Nearest,
1566            ..Default::default()
1567        });
1568        flipped.set_camera(screen_camera([256.0, 256.0]));
1569        flipped.push(
1570            [128.0, 128.0],
1571            [128.0, 128.0],
1572            0.0,
1573            [0.5, 0.5],
1574            true,
1575            false,
1576            [0.0, 0.0],
1577            [1.0, 1.0],
1578            [1.0, 1.0, 1.0, 1.0],
1579            0,
1580        );
1581        let scene = Scene {
1582            clear_color: Color::from_rgba(0, 0, 0, 255),
1583            nodes: vec![SceneNode::Callback {
1584                rect: Rect {
1585                    x: 0.0,
1586                    y: 0.0,
1587                    w: 256.0,
1588                    h: 256.0,
1589                },
1590                payload: Callback::new(flipped),
1591            }],
1592        };
1593        let px = renderer
1594            .render_rgba(&scene, Some([0.0, 0.0, 0.0, 1.0]))
1595            .expect("offscreen render");
1596        let at = |x: u32, y: u32| -> [u8; 4] {
1597            let i = ((y * 256 + x) * 4) as usize;
1598            [px[i], px[i + 1], px[i + 2], px[i + 3]]
1599        };
1600        assert_eq!(at(64, 64), [0, 255, 0, 255], "flipped top-left green");
1601        assert_eq!(at(191, 191), [0, 0, 255, 255], "flipped bottom-right blue");
1602    }
1603
1604    /// Sibling batch nodes with distinct ids draw independently. GPU
1605    /// resources are keyed by batch id, so two runs sharing one id would
1606    /// collide and only the last prepared run would paint; the unified
1607    /// producer gives every run its own id for exactly this reason.
1608    #[test]
1609    fn distinct_batch_ids_compose_in_one_scene() {
1610        use repose_core::{Color, Rect, Scene};
1611        use repose_render_wgpu::offscreen::OffscreenRenderer;
1612        use std::sync::Arc;
1613
1614        let mut renderer = match OffscreenRenderer::new_blocking(256, 256, 1) {
1615            Ok(r) => r,
1616            Err(e) => {
1617                eprintln!("SKIP batch compose test (no GPU): {e}");
1618                return;
1619            }
1620        };
1621        let page = vec![
1622            255, 0, 0, 255, //
1623            0, 255, 0, 255, //
1624            0, 0, 255, 255, //
1625            255, 255, 255, 255,
1626        ];
1627        let uploads: Arc<[AtlasUpload]> = Arc::from(vec![AtlasUpload {
1628            page: 0,
1629            x: 0,
1630            y: 0,
1631            w: 2,
1632            h: 2,
1633            rgba: page,
1634        }]);
1635        let desc = BatchDesc {
1636            layer_size: 2,
1637            layers: 1,
1638            filter: TextureFilter::Nearest,
1639            ..Default::default()
1640        };
1641        let mut left = SpriteBatch::with_id("test.compose.left", desc.clone());
1642        left.set_camera(screen_camera([256.0, 256.0]));
1643        left.push(
1644            [64.0, 128.0],
1645            [64.0, 64.0],
1646            0.0,
1647            [0.5, 0.5],
1648            false,
1649            false,
1650            [0.0, 0.0],
1651            [1.0, 1.0],
1652            [1.0, 1.0, 1.0, 1.0],
1653            0,
1654        );
1655        let mut right = SpriteBatch::with_id("test.compose.right", desc.clone());
1656        right.set_camera(screen_camera([256.0, 256.0]));
1657        right.push(
1658            [192.0, 128.0],
1659            [64.0, 64.0],
1660            0.0,
1661            [0.5, 0.5],
1662            false,
1663            false,
1664            [0.0, 0.0],
1665            [1.0, 1.0],
1666            [1.0, 1.0, 1.0, 1.0],
1667            0,
1668        );
1669        let full = Rect {
1670            x: 0.0,
1671            y: 0.0,
1672            w: 256.0,
1673            h: 256.0,
1674        };
1675        let scene = Scene {
1676            clear_color: Color::from_rgba(0, 0, 0, 255),
1677            nodes: vec![
1678                BatchFrame::new(left, uploads.clone()).node(full),
1679                BatchFrame::new(right, uploads.clone()).node(full),
1680            ],
1681        };
1682        let px = renderer
1683            .render_rgba(&scene, Some([0.0, 0.0, 0.0, 1.0]))
1684            .expect("offscreen render");
1685        let at = |x: u32, y: u32| -> [u8; 4] {
1686            let i = ((y * 256 + x) * 4) as usize;
1687            [px[i], px[i + 1], px[i + 2], px[i + 3]]
1688        };
1689        assert_eq!(at(40, 104), [255, 0, 0, 255], "left run red");
1690        assert_eq!(at(168, 104), [255, 0, 0, 255], "right run red");
1691    }
1692}