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//!
25//! Lighting: group(2) carries a light map, always bound (the identity map when
26//! a batch names none), and the fragment shader consumes it as
27//! `color * modulate * map.a + map.rgb`. Name one with
28//! [`SpriteBatch::set_light_source`] and put a [`Light2dPass`] with the same id
29//! and camera in the same scene and target; the light pass publishes its map
30//! before any batch paints, whatever the node order.
31
32use std::collections::{HashMap, HashSet};
33use std::sync::{Arc, Mutex};
34
35use bytemuck::{Pod, Zeroable};
36use glam::Mat4;
37use repose_render_wgpu::{CallbackRenderPass, CallbackResources, ScreenDescriptor, WgpuCallback};
38use wgpu::util::DeviceExt;
39
40use super::{SpriteBlend, SpriteInstance};
41
42/// Texture sampling for atlas layers.
43#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
44pub enum TextureFilter {
45    /// Pixel-art crisp (nt default).
46    #[default]
47    Nearest,
48    Linear,
49}
50
51/// Batch construction parameters. Pages map 1:1 onto array layers.
52#[derive(Clone, Copy, Debug)]
53pub struct BatchDesc {
54    /// Square atlas layer edge in pixels.
55    pub layer_size: u32,
56    /// Array layer count (= max atlas pages).
57    pub layers: u32,
58    pub filter: TextureFilter,
59    /// Atlas content generation behind the uploads.
60    pub uploads_gen: u64,
61}
62
63impl Default for BatchDesc {
64    fn default() -> Self {
65        Self {
66            layer_size: 2048,
67            layers: 4,
68            filter: TextureFilter::Nearest,
69            uploads_gen: 0,
70        }
71    }
72}
73
74/// One pending atlas upload: blit `rgba` (tight `w`*`h`*4 bytes) into
75/// `page` at (`x`, `y`). Built from a `repame-atlas` drain by the game.
76#[derive(Clone, Debug)]
77pub struct AtlasUpload {
78    pub page: u32,
79    pub x: u32,
80    pub y: u32,
81    pub w: u32,
82    pub h: u32,
83    pub rgba: Vec<u8>,
84}
85
86impl AtlasUpload {
87    /// Bridge an atlas placement to a GPU upload. `rgba` must hold the
88    /// sprite's pixels row-major, `w`*`h`*4 bytes; mismatches are dropped
89    /// with a warning at `prepare` time.
90    pub fn from_write(write: &repame_atlas::AtlasWrite, rgba: Vec<u8>) -> Self {
91        Self {
92            page: write.page,
93            x: write.x,
94            y: write.y,
95            w: write.w,
96            h: write.h,
97            rgba,
98        }
99    }
100
101    /// 1x1 white texel upload for [`repame_atlas::Atlas::ensure_white`]:
102    /// pair with the drained write for the white key (usually 1x1 at the
103    /// returned `x/y/page`). Sampling it gives solid tint (particles,
104    /// untextured quads) instead of random atlas art.
105    pub fn white(page: u32, x: u32, y: u32) -> Self {
106        Self {
107            page,
108            x,
109            y,
110            w: 1,
111            h: 1,
112            rgba: vec![255, 255, 255, 255],
113        }
114    }
115
116    /// White upload built straight from the drained [`repame_atlas::AtlasWrite`]
117    /// for the white key.
118    pub fn white_for(write: &repame_atlas::AtlasWrite) -> Self {
119        Self {
120            page: write.page,
121            x: write.x,
122            y: write.y,
123            w: write.w.max(1),
124            h: write.h.max(1),
125            rgba: vec![255; write.w.max(1) as usize * write.h.max(1) as usize * 4],
126        }
127    }
128}
129
130/// Transform rows mapping quad corners (-0.5..0.5) to world coords,
131/// honoring size, rotation, anchor, and mirror. Matches the canvas path
132/// pixel-for-pixel at the default anchor: corner (-0.5,-0.5) lands on
133/// `center - size / 2`.
134pub fn instance_rows(
135    center: [f32; 2],
136    size: [f32; 2],
137    rotation: f32,
138    anchor: [f32; 2],
139    flip_x: bool,
140    flip_y: bool,
141) -> ([f32; 4], [f32; 4]) {
142    let (c, s) = (rotation.cos(), rotation.sin());
143    let sx = if flip_x { -size[0] } else { size[0] };
144    let sy = if flip_y { -size[1] } else { size[1] };
145    // Normalized offset of the anchor from the quad middle: the anchor
146    // point itself must land on `center`.
147    let (ax, ay) = (0.5 - anchor[0], 0.5 - anchor[1]);
148    (
149        [c * sx, -s * sy, 0.0, center[0] + c * sx * ax - s * sy * ay],
150        [s * sx, c * sy, 0.0, center[1] + s * sx * ax + c * sy * ay],
151    )
152}
153
154/// UV rect for one cell of a sprite-sheet grid: `hframes` columns by
155/// `vframes` rows, `frame` counted row-major from the top-left.
156///
157/// Returns `(uv_min, uv_max)` normalized to `0..1`, ready for
158/// [`SpriteInstance`] `uv_min`/`uv_max`. The grid
159/// covers the whole texture: cell `(col, row)` spans
160/// `col/hframes..(col+1)/hframes` by `row/vframes..(row+1)/vframes`.
161///
162/// - Out-of-range `frame` values clamp to the last cell instead of
163///   wrapping; advance the frame index with your animation clock.
164/// - Degenerate grids (`hframes` or `vframes` of `0`) yield the full
165///   texture, matching a single-frame sprite.
166///
167/// ```rust
168/// use repame_view2d::{frame_uv, sprite_aabb};
169///
170/// // 4x2 sheet: frame 5 is column 1, row 1.
171/// let (mn, mx) = frame_uv(4, 2, 5);
172/// assert_eq!(mn, [0.25, 0.5]);
173/// assert_eq!(mx, [0.5, 1.0]);
174/// ```
175pub fn frame_uv(hframes: u32, vframes: u32, frame: u32) -> ([f32; 2], [f32; 2]) {
176    let hf = hframes.max(1);
177    let vf = vframes.max(1);
178    let f = frame.min(hf * vf - 1);
179    let col = f % hf;
180    let row = f / hf;
181    (
182        [col as f32 / hf as f32, row as f32 / vf as f32],
183        [(col + 1) as f32 / hf as f32, (row + 1) as f32 / vf as f32],
184    )
185}
186
187/// World-space axis-aligned bounds of a sprite quad.
188///
189/// Returns `([min_x, min_y], [max_x, max_y])` through the same
190/// [`instance_rows`] math as the GPU batch, so the box matches the drawn
191/// quad: exact for unrotated sprites, the outer box for rotated ones.
192/// `flip_x`/`flip_y` do not change the box (mirroring preserves extents).
193///
194/// Useful for click hit-testing and layout: a press at world point `p`
195/// hits the sprite when `min <= p <= max`.
196///
197/// ```rust
198/// use repame_view2d::{frame_uv, sprite_aabb};
199///
200/// let (mn, mx) = sprite_aabb([400.0, 300.0], [64.0, 80.0], 0.0, [0.5, 0.5], false, false);
201/// assert_eq!(mn, [368.0, 260.0]);
202/// assert_eq!(mx, [432.0, 340.0]);
203/// ```
204pub fn sprite_aabb(
205    center: [f32; 2],
206    size: [f32; 2],
207    rotation: f32,
208    anchor: [f32; 2],
209    flip_x: bool,
210    flip_y: bool,
211) -> ([f32; 2], [f32; 2]) {
212    let (row0, row1) = instance_rows(center, size, rotation, anchor, flip_x, flip_y);
213    let mut min = [f32::INFINITY, f32::INFINITY];
214    let mut max = [f32::NEG_INFINITY, f32::NEG_INFINITY];
215    for corner in [[-0.5, -0.5], [0.5, -0.5], [0.5, 0.5], [-0.5, 0.5]] {
216        let x = row0[0] * corner[0] + row0[1] * corner[1] + row0[3];
217        let y = row1[0] * corner[0] + row1[1] * corner[1] + row1[3];
218        min[0] = min[0].min(x);
219        min[1] = min[1].min(y);
220        max[0] = max[0].max(x);
221        max[1] = max[1].max(y);
222    }
223    (min, max)
224}
225
226/// Y-down orthographic camera: world (0,0) is the top-left of the
227/// viewport, matching canvas orientation by construction. Standalone
228/// batches and tests use this directly; viewport snapshots frame through
229/// `fit_view_proj` (contain-fit + look) instead.
230pub fn screen_camera(viewport_px: [f32; 2]) -> Mat4 {
231    // Right-handed, 0..1 depth: matches wgpu NDC.
232    glam::camera::rh::proj::directx::orthographic(
233        0.0,
234        viewport_px[0].max(1.0),
235        viewport_px[1].max(1.0),
236        0.0,
237        -1000.0,
238        1000.0,
239    )
240}
241
242#[repr(C)]
243#[derive(Clone, Copy, Pod, Zeroable)]
244struct CameraUniform {
245    view_proj: [[f32; 4]; 4],
246}
247
248#[repr(C)]
249#[derive(Clone, Copy, Pod, Zeroable)]
250struct BatchInstance {
251    row0: [f32; 4],
252    row1: [f32; 4],
253    uv_min: [f32; 2],
254    uv_max: [f32; 2],
255    tint: [f32; 4],
256    page: f32,
257    z: f32,
258    flags: u32,
259    /// Half a texel in normalized UV, used to inset the UV rect to texel
260    /// centres in the vertex shader. Zero disables the inset.
261    texel: f32,
262    /// Light layer bits; the vertex shader resolves the low bit to a layer.
263    light_mask: u32,
264}
265
266// Locked to `shaders/sprite.wgsl::Instance` (row0@0, row1@16,
267// uv_min@32, uv_max@40, tint@48, page@64, z@68, flags@72, texel@76,
268// light_mask@80; stride 84).
269// If this fires, update the WGSL offsets and the vertex buffer layout
270// below together.
271const _: () = assert!(size_of::<BatchInstance>() == 84);
272
273/// Group 2 of the sprite shaders: the light map an instance's layer reads.
274/// Matches the WGSL `Lightmap` struct (48 bytes).
275#[repr(C)]
276#[derive(Clone, Copy, Pod, Zeroable)]
277struct LightmapUniform {
278    /// `world * xform.xy + xform.zw` is the light map uv.
279    xform: [f32; 4],
280    /// rgb modulates the item colour before light; a is unused.
281    modulate: [f32; 4],
282    /// x: highest layer an instance may sample (the identity layer).
283    layer: [u32; 4],
284}
285
286const _: () = assert!(size_of::<LightmapUniform>() == 48);
287
288/// Identity light map: `(rgb = 0, a = 1)` on every texel, so an unlit batch
289/// multiplies by one and adds nothing. Two layers because a one-layer array
290/// texture is unsafe to sample on GL/ANGLE (see [`atlas_shape`]).
291const IDENTITY_LIGHTMAP_LAYERS: u32 = 2;
292
293/// Per-frame snapshot batch. `Send + Sync` so it can cross into the
294/// compositor thread via [`repose_render_wgpu::Callback`].
295///
296/// Each batch id owns its pipelines/instances in `CallbackResources`
297/// (like `FullscreenPass`'s id map). Re-keying the atlas (a new
298/// [`BatchDesc`] or `uploads_gen`) drops its contents; the game re-fills
299/// it with the uploads that came with the new descriptor.
300pub struct SpriteBatch {
301    id: String,
302    desc: BatchDesc,
303    camera: [[f32; 4]; 4],
304    instances: Vec<BatchInstance>,
305    uploads: Vec<AtlasUpload>,
306    light_source: Option<String>,
307    light_modulate: [f32; 3],
308}
309
310impl SpriteBatch {
311    pub fn new(desc: BatchDesc) -> Self {
312        Self::with_id("sprite_batch.default", desc)
313    }
314
315    pub fn with_id(id: impl Into<String>, desc: BatchDesc) -> Self {
316        Self {
317            id: id.into(),
318            desc,
319            camera: Mat4::IDENTITY.to_cols_array_2d(),
320            instances: Vec::new(),
321            uploads: Vec::new(),
322            light_source: None,
323            light_modulate: [1.0, 1.0, 1.0],
324        }
325    }
326
327    pub fn id(&self) -> &str {
328        &self.id
329    }
330
331    pub fn clear(&mut self) {
332        self.instances.clear();
333        self.uploads.clear();
334    }
335
336    pub fn len(&self) -> usize {
337        self.instances.len()
338    }
339
340    pub fn is_empty(&self) -> bool {
341        self.instances.is_empty()
342    }
343
344    /// View-projection matrix, y-down screen convention (see
345    /// [`screen_camera`]).
346    pub fn set_camera(&mut self, view_proj: Mat4) {
347        self.camera = view_proj.to_cols_array_2d();
348    }
349
350    /// Queue atlas uploads, applied in the next `prepare`.
351    pub fn upload(&mut self, upload: AtlasUpload) {
352        self.uploads.push(upload);
353    }
354
355    /// Queue several uploads at once (per-frame atlas drains).
356    pub fn extend_uploads(&mut self, uploads: impl IntoIterator<Item = AtlasUpload>) {
357        self.uploads.extend(uploads);
358    }
359
360    /// Light every sprite in this batch from the light map of the
361    /// [`Light2dPass`](crate::Light2dPass) with this id.
362    ///
363    /// The pass and the batch must share one camera
364    /// ([`set_camera`](Self::set_camera) on both); the light map is published
365    /// as a world-space rect, so a batch drawn into any rectangle of the same
366    /// target lines up without further setup. A batch whose pass has not
367    /// prepared yet draws unlit for that frame.
368    pub fn set_light_source(&mut self, id: impl Into<String>) {
369        self.light_source = Some(id.into());
370    }
371
372    /// Draw unlit: the identity light map, modulation ignored.
373    pub fn clear_light_source(&mut self) {
374        self.light_source = None;
375    }
376
377    /// Scene darkness for this batch: multiplies every sprite's colour before
378    /// the light map adds light. `[1.0; 3]` (default) is neutral and `[0.0; 3]`
379    /// is black; a batch with no light source ignores it. Values are clamped to
380    /// `0..` and a non-finite channel falls back to `1.0`, so one bad frame
381    /// cannot blacken or NaN the whole batch.
382    pub fn set_light_modulate(&mut self, rgb: [f32; 3]) {
383        self.light_modulate =
384            rgb.map(|c| if c.is_finite() { c.max(0.0) } else { 1.0 });
385    }
386
387    /// Push one textured quad.
388    #[allow(clippy::too_many_arguments)]
389    pub fn push(
390        &mut self,
391        center: [f32; 2],
392        size: [f32; 2],
393        rotation: f32,
394        anchor: [f32; 2],
395        flip_x: bool,
396        flip_y: bool,
397        uv_min: [f32; 2],
398        uv_max: [f32; 2],
399        tint: [f32; 4],
400        page: u32,
401    ) {
402        self.push_blended(
403            center,
404            size,
405            rotation,
406            anchor,
407            flip_x,
408            flip_y,
409            uv_min,
410            uv_max,
411            tint,
412            page,
413            0.0,
414            SpriteBlend::Alpha,
415        );
416    }
417
418    /// Push one textured quad on an explicit light layer.
419    #[allow(clippy::too_many_arguments)]
420    pub fn push_masked(
421        &mut self,
422        center: [f32; 2],
423        size: [f32; 2],
424        rotation: f32,
425        anchor: [f32; 2],
426        flip_x: bool,
427        flip_y: bool,
428        uv_min: [f32; 2],
429        uv_max: [f32; 2],
430        tint: [f32; 4],
431        page: u32,
432        light_mask: u32,
433    ) {
434        self.push_blended_masked(
435            center,
436            size,
437            rotation,
438            anchor,
439            flip_x,
440            flip_y,
441            uv_min,
442            uv_max,
443            tint,
444            page,
445            0.0,
446            SpriteBlend::Alpha,
447            light_mask,
448        );
449    }
450
451    /// Push with explicit `z` + blend; lit by layer 0 (light mask `1`). Use
452    /// [`Self::push_blended_masked`] to put the sprite on another layer.
453    #[allow(clippy::too_many_arguments)]
454    pub fn push_blended(
455        &mut self,
456        center: [f32; 2],
457        size: [f32; 2],
458        rotation: f32,
459        anchor: [f32; 2],
460        flip_x: bool,
461        flip_y: bool,
462        uv_min: [f32; 2],
463        uv_max: [f32; 2],
464        tint: [f32; 4],
465        page: u32,
466        z: f32,
467        blend: SpriteBlend,
468    ) {
469        self.push_blended_masked(
470            center, size, rotation, anchor, flip_x, flip_y, uv_min, uv_max, tint, page, z, blend,
471            1,
472        );
473    }
474
475    /// Push with explicit `z`, blend, and light layer bits.
476    #[allow(clippy::too_many_arguments)]
477    pub fn push_blended_masked(
478        &mut self,
479        center: [f32; 2],
480        size: [f32; 2],
481        rotation: f32,
482        anchor: [f32; 2],
483        flip_x: bool,
484        flip_y: bool,
485        uv_min: [f32; 2],
486        uv_max: [f32; 2],
487        tint: [f32; 4],
488        page: u32,
489        z: f32,
490        blend: SpriteBlend,
491        light_mask: u32,
492    ) {
493        let (row0, row1) = instance_rows(center, size, rotation, anchor, flip_x, flip_y);
494        let flags = match blend {
495            SpriteBlend::Alpha => 0,
496            SpriteBlend::Additive => 1,
497            SpriteBlend::Multiply => 2,
498        };
499        self.instances.push(BatchInstance {
500            row0,
501            row1,
502            uv_min,
503            uv_max,
504            tint,
505            page: page as f32,
506            z,
507            flags,
508            texel: 0.5 / self.desc.layer_size.max(1) as f32,
509            light_mask,
510        });
511    }
512
513    /// Bridge a snapshot sprite into the batch.
514    ///
515    /// The atlas and the render target are both `Rgba8UnormSrgb`, so the
516    /// sampled texel and the written pixel are already in linear space and
517    /// the tint has to be linearized here; [`Self::push`] takes one
518    /// directly.
519    pub fn push_sprite(&mut self, s: &SpriteInstance) {
520        self.push_blended_masked(
521            [s.center.x, s.center.y],
522            [s.size.x, s.size.y],
523            s.rotation,
524            [s.anchor.x, s.anchor.y],
525            s.flip_x,
526            s.flip_y,
527            [s.uv_min.x, s.uv_min.y],
528            [s.uv_max.x, s.uv_max.y],
529            super::rgba8(s.color).to_linear(),
530            s.page,
531            s.z,
532            s.blend,
533            s.light_mask,
534        );
535    }
536}
537
538#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
539struct AtlasDescriptor {
540    layer_size: u32,
541    layers: u32,
542    filter: TextureFilter,
543}
544
545#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
546struct AtlasKey {
547    descriptor: AtlasDescriptor,
548    generation: u64,
549}
550
551struct AtlasLayout {
552    tex_layout: wgpu::BindGroupLayout,
553    sampler: wgpu::Sampler,
554}
555
556struct AtlasResources {
557    texture: wgpu::Texture,
558    tex_bind: wgpu::BindGroup,
559    applied_gen: Arc<Mutex<Option<u64>>>,
560    /// Generation already reported as unfilled, so the report lands once per
561    /// generation rather than once per frame.
562    starved_gen: Arc<Mutex<Option<u64>>>,
563}
564
565struct BatchEntry {
566    key: (wgpu::TextureFormat, u32, u32, u32, TextureFilter),
567    pipeline_alpha: wgpu::RenderPipeline,
568    pipeline_multiply: wgpu::RenderPipeline,
569    pipeline_additive: wgpu::RenderPipeline,
570    corners: wgpu::Buffer,
571    instances: wgpu::Buffer,
572    instance_cap: usize,
573    /// Sorted ranges: `0..alpha` alpha, `alpha..multiply_end` multiply,
574    /// `multiply_end..total` additive.
575    last_alpha: u32,
576    last_multiply_end: u32,
577    last_total: u32,
578    camera: wgpu::Buffer,
579    cam_bind: wgpu::BindGroup,
580    atlas: Arc<AtlasResources>,
581    atlas_key: AtlasKey,
582    /// Per-batch light uniforms (world -> uv, modulate, layer clamp).
583    light_uniform: wgpu::Buffer,
584    /// Group 2 for the sprite pipelines: the light map this batch reads, or
585    /// the shared identity map when it has no light source.
586    light_bind: Arc<wgpu::BindGroup>,
587    /// What `light_bind` was built from, so it is rebuilt only when the light
588    /// map's texture changes (the rect rides in the uniform instead).
589    light_key: Option<(String, u64)>,
590    /// Whether the missing-light-map warning was already logged, so a broken
591    /// setup is loud once instead of every frame.
592    light_starved: bool,
593}
594
595struct BatchResources {
596    batches: HashMap<String, BatchEntry>,
597    atlases: HashMap<AtlasKey, Arc<AtlasResources>>,
598    layouts: HashMap<AtlasDescriptor, Arc<AtlasLayout>>,
599    light_layout: wgpu::BindGroupLayout,
600    light_sampler: wgpu::Sampler,
601    /// Kept alive for `identity_bind`, which holds only the view.
602    #[allow(dead_code)]
603    identity_lightmap: wgpu::Texture,
604    identity_bind: Arc<wgpu::BindGroup>,
605}
606
607impl BatchResources {
608    fn prune_atlases(&mut self) {
609        let live: HashSet<AtlasKey> = self.batches.values().map(|entry| entry.atlas_key).collect();
610        self.atlases.retain(|key, _| live.contains(key));
611    }
612}
613
614const CORNERS: &[f32] = &[
615    -0.5, -0.5, //
616    0.5, -0.5, //
617    0.5, 0.5, //
618    -0.5, -0.5, //
619    0.5, 0.5, //
620    -0.5, 0.5, //
621];
622
623fn sampler_desc(filter: TextureFilter) -> wgpu::SamplerDescriptor<'static> {
624    let f = match filter {
625        TextureFilter::Nearest => wgpu::FilterMode::Nearest,
626        TextureFilter::Linear => wgpu::FilterMode::Linear,
627    };
628    let m = match filter {
629        TextureFilter::Nearest => wgpu::MipmapFilterMode::Nearest,
630        TextureFilter::Linear => wgpu::MipmapFilterMode::Linear,
631    };
632    wgpu::SamplerDescriptor {
633        label: Some("sprite_batch_sampler"),
634        address_mode_u: wgpu::AddressMode::ClampToEdge,
635        address_mode_v: wgpu::AddressMode::ClampToEdge,
636        address_mode_w: wgpu::AddressMode::ClampToEdge,
637        mag_filter: f,
638        min_filter: f,
639        mipmap_filter: m,
640        lod_min_clamp: 0.0,
641        lod_max_clamp: 1.0,
642        compare: None,
643        anisotropy_clamp: 1,
644        border_color: None,
645    }
646}
647
648/// The atlas view dimension and the shader that matches it.
649///
650/// A single-layer atlas is a plain `D2` texture. GL/ANGLE (which is what a
651/// browser actually hands wgpu) assumes `D2` for a one-layer texture and
652/// misreports a `D2Array` view of it, after which sampling returns black and
653/// every sprite disappears with no validation error anywhere. So a one-layer
654/// atlas is never made an array in the first place.
655pub(crate) fn atlas_shape(layers: u32) -> (wgpu::TextureViewDimension, bool) {
656    if layers <= 1 {
657        (wgpu::TextureViewDimension::D2, false)
658    } else {
659        (wgpu::TextureViewDimension::D2Array, true)
660    }
661}
662
663pub(crate) fn sprite_shader(layers: u32) -> &'static str {
664    if atlas_shape(layers).1 {
665        include_str!("../shaders/sprite.wgsl")
666    } else {
667        include_str!("../shaders/sprite_single.wgsl")
668    }
669}
670
671/// Group 2 layout shared by every batch: the light map array, its sampler, and
672/// the per-batch light uniform.
673fn create_light_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout {
674    device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
675        label: Some("sprite_batch_light_bgl"),
676        entries: &[
677            wgpu::BindGroupLayoutEntry {
678                binding: 0,
679                visibility: wgpu::ShaderStages::FRAGMENT,
680                ty: wgpu::BindingType::Texture {
681                    sample_type: wgpu::TextureSampleType::Float { filterable: true },
682                    view_dimension: wgpu::TextureViewDimension::D2Array,
683                    multisampled: false,
684                },
685                count: None,
686            },
687            wgpu::BindGroupLayoutEntry {
688                binding: 1,
689                visibility: wgpu::ShaderStages::FRAGMENT,
690                ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
691                count: None,
692            },
693            wgpu::BindGroupLayoutEntry {
694                binding: 2,
695                visibility: wgpu::ShaderStages::FRAGMENT,
696                ty: wgpu::BindingType::Buffer {
697                    ty: wgpu::BufferBindingType::Uniform,
698                    has_dynamic_offset: false,
699                    min_binding_size: None,
700                },
701                count: None,
702            },
703        ],
704    })
705}
706
707/// The identity light map every batch binds until it names a real one: a
708/// `1x1` texel of `(rgb = 0, a = 1)`, which the sprite shader's
709/// `color * modulate * a + rgb` turns into plain `color`.
710fn create_identity_lightmap(
711    device: &wgpu::Device,
712    queue: &wgpu::Queue,
713    layout: &wgpu::BindGroupLayout,
714) -> (wgpu::Texture, Arc<wgpu::BindGroup>) {
715    let texture = device.create_texture(&wgpu::TextureDescriptor {
716        label: Some("sprite_batch_identity_lightmap"),
717        size: wgpu::Extent3d {
718            width: 1,
719            height: 1,
720            depth_or_array_layers: IDENTITY_LIGHTMAP_LAYERS,
721        },
722        mip_level_count: 1,
723        sample_count: 1,
724        dimension: wgpu::TextureDimension::D2,
725        format: wgpu::TextureFormat::Rgba8Unorm,
726        usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
727        view_formats: &[],
728    });
729    queue.write_texture(
730        wgpu::TexelCopyTextureInfo {
731            texture: &texture,
732            mip_level: 0,
733            origin: wgpu::Origin3d::ZERO,
734            aspect: wgpu::TextureAspect::All,
735        },
736        &[0, 0, 0, 255, 0, 0, 0, 255],
737        wgpu::TexelCopyBufferLayout {
738            offset: 0,
739            bytes_per_row: Some(4),
740            rows_per_image: Some(1),
741        },
742        wgpu::Extent3d {
743            width: 1,
744            height: 1,
745            depth_or_array_layers: IDENTITY_LIGHTMAP_LAYERS,
746        },
747    );
748    let view = texture.create_view(&wgpu::TextureViewDescriptor {
749        dimension: Some(wgpu::TextureViewDimension::D2Array),
750        ..Default::default()
751    });
752    let uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
753        label: Some("sprite_batch_identity_light"),
754        contents: bytemuck::bytes_of(&LightmapUniform {
755            xform: [0.0; 4],
756            modulate: [1.0, 1.0, 1.0, 0.0],
757            layer: [0; 4],
758        }),
759        usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
760    });
761    let sampler = device.create_sampler(&sampler_desc(TextureFilter::Nearest));
762    let bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
763        label: Some("sprite_batch_identity_light_bg"),
764        layout,
765        entries: &[
766            wgpu::BindGroupEntry {
767                binding: 0,
768                resource: wgpu::BindingResource::TextureView(&view),
769            },
770            wgpu::BindGroupEntry {
771                binding: 1,
772                resource: wgpu::BindingResource::Sampler(&sampler),
773            },
774            wgpu::BindGroupEntry {
775                binding: 2,
776                resource: uniform.as_entire_binding(),
777            },
778        ],
779    });
780    (texture, Arc::new(bind))
781}
782
783fn create_atlas_layout(device: &wgpu::Device, filter: TextureFilter, layers: u32) -> AtlasLayout {    let (dimension, _) = atlas_shape(layers);
784    let sampler = device.create_sampler(&sampler_desc(filter));
785    let tex_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
786        label: Some("sprite_batch_tex_bgl"),
787        entries: &[
788            wgpu::BindGroupLayoutEntry {
789                binding: 0,
790                visibility: wgpu::ShaderStages::FRAGMENT,
791                ty: wgpu::BindingType::Texture {
792                    sample_type: wgpu::TextureSampleType::Float { filterable: true },
793                    view_dimension: dimension,
794                    multisampled: false,
795                },
796                count: None,
797            },
798            wgpu::BindGroupLayoutEntry {
799                binding: 1,
800                visibility: wgpu::ShaderStages::FRAGMENT,
801                ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
802                count: None,
803            },
804        ],
805    });
806    AtlasLayout {
807        tex_layout,
808        sampler,
809    }
810}
811
812fn create_atlas(device: &wgpu::Device, key: AtlasKey, layout: &AtlasLayout) -> AtlasResources {
813    let descriptor = key.descriptor;
814    let texture = device.create_texture(&wgpu::TextureDescriptor {
815        label: Some("sprite_batch_atlas"),
816        size: wgpu::Extent3d {
817            width: descriptor.layer_size,
818            height: descriptor.layer_size,
819            depth_or_array_layers: descriptor.layers,
820        },
821        mip_level_count: 1,
822        sample_count: 1,
823        dimension: wgpu::TextureDimension::D2,
824        format: wgpu::TextureFormat::Rgba8UnormSrgb,
825        usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
826        view_formats: &[],
827    });
828    let (dimension, _) = atlas_shape(descriptor.layers);
829    let view = texture.create_view(&wgpu::TextureViewDescriptor {
830        dimension: Some(dimension),
831        ..Default::default()
832    });
833    let tex_bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
834        label: Some("sprite_batch_tex_bg"),
835        layout: &layout.tex_layout,
836        entries: &[
837            wgpu::BindGroupEntry {
838                binding: 0,
839                resource: wgpu::BindingResource::TextureView(&view),
840            },
841            wgpu::BindGroupEntry {
842                binding: 1,
843                resource: wgpu::BindingResource::Sampler(&layout.sampler),
844            },
845        ],
846    });
847    AtlasResources {
848        texture,
849        tex_bind,
850        applied_gen: Arc::new(Mutex::new(None)),
851        starved_gen: Arc::new(Mutex::new(None)),
852    }
853}
854
855impl SpriteBatch {
856    fn ensure_resources(
857        &self,
858        device: &wgpu::Device,
859        queue: &wgpu::Queue,
860        screen: &ScreenDescriptor,
861        resources: &mut CallbackResources,
862    ) {
863        let descriptor = AtlasDescriptor {
864            layer_size: self.desc.layer_size,
865            layers: self.desc.layers,
866            filter: self.desc.filter,
867        };
868        let atlas_key = AtlasKey {
869            descriptor,
870            generation: self.desc.uploads_gen,
871        };
872        let key = (
873            screen.target_format,
874            screen.sample_count,
875            self.desc.layer_size,
876            self.desc.layers,
877            self.desc.filter,
878        );
879        if resources.get::<BatchResources>().is_none() {
880            let light_layout = create_light_layout(device);
881            let (identity_lightmap, identity_bind) =
882                create_identity_lightmap(device, queue, &light_layout);
883            resources.insert(BatchResources {
884                batches: HashMap::new(),
885                atlases: HashMap::new(),
886                layouts: HashMap::new(),
887                light_layout,
888                light_sampler: device.create_sampler(&sampler_desc(TextureFilter::Linear)),
889                identity_lightmap,
890                identity_bind,
891            });
892        }
893        let layout = resources
894            .get_mut::<BatchResources>()
895            .expect("batch resources initialized")
896            .layouts
897            .entry(descriptor)
898            .or_insert_with(|| {
899                Arc::new(create_atlas_layout(
900                    device,
901                    descriptor.filter,
902                    descriptor.layers,
903                ))
904            })
905            .clone();
906        let atlas = resources
907            .get_mut::<BatchResources>()
908            .expect("batch resources initialized")
909            .atlases
910            .entry(atlas_key)
911            .or_insert_with(|| Arc::new(create_atlas(device, atlas_key, &layout)))
912            .clone();
913        let needs = resources
914            .get::<BatchResources>()
915            .expect("batch resources initialized")
916            .batches
917            .get(self.id.as_str())
918            .is_none_or(|entry| entry.key != key);
919        if !needs {
920            if let Some(all) = resources.get_mut::<BatchResources>()
921                && let Some(entry) = all.batches.get_mut(self.id.as_str())
922            {
923                entry.atlas = atlas;
924                entry.atlas_key = atlas_key;
925                all.prune_atlases();
926            }
927            return;
928        }
929        if resources
930            .get::<BatchResources>()
931            .is_some_and(|all| all.batches.contains_key(self.id.as_str()))
932        {
933            log::warn!(
934                "sprite_batch[{}]: rebuilding pipeline for format/sample/descriptor change",
935                self.id
936            );
937        }
938        let light_layout = resources
939            .get::<BatchResources>()
940            .expect("batch resources initialized")
941            .light_layout
942            .clone();
943        let light_uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
944            label: Some("sprite_batch_light_uniforms"),
945            contents: bytemuck::bytes_of(&LightmapUniform {
946                xform: [0.0; 4],
947                modulate: [1.0, 1.0, 1.0, 0.0],
948                layer: [0; 4],
949            }),
950            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
951        });
952        let light_bind = resources
953            .get::<BatchResources>()
954            .expect("batch resources initialized")
955            .identity_bind
956            .clone();
957        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
958            label: Some("sprite_batch"),
959            source: wgpu::ShaderSource::Wgsl(sprite_shader(self.desc.layers).into()),
960        });
961        let corners = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
962            label: Some("sprite_batch_corners"),
963            contents: bytemuck::cast_slice(CORNERS),
964            usage: wgpu::BufferUsages::VERTEX,
965        });
966        let camera = device.create_buffer(&wgpu::BufferDescriptor {
967            label: Some("sprite_batch_camera"),
968            size: 64,
969            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
970            mapped_at_creation: false,
971        });
972        let instances = device.create_buffer(&wgpu::BufferDescriptor {
973            label: Some("sprite_batch_instances"),
974            size: (self.instances.len().max(1) * size_of::<BatchInstance>()) as u64,
975            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
976            mapped_at_creation: false,
977        });
978        let cam_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
979            label: Some("sprite_batch_cam_bgl"),
980            entries: &[wgpu::BindGroupLayoutEntry {
981                binding: 0,
982                visibility: wgpu::ShaderStages::VERTEX,
983                ty: wgpu::BindingType::Buffer {
984                    ty: wgpu::BufferBindingType::Uniform,
985                    has_dynamic_offset: false,
986                    min_binding_size: None,
987                },
988                count: None,
989            }],
990        });
991        let cam_bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
992            label: Some("sprite_batch_cam_bg"),
993            layout: &cam_layout,
994            entries: &[wgpu::BindGroupEntry {
995                binding: 0,
996                resource: camera.as_entire_binding(),
997            }],
998        });
999        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1000            label: Some("sprite_batch_pl"),
1001            bind_group_layouts: &[
1002                Some(&cam_layout),
1003                Some(&layout.tex_layout),
1004                Some(&light_layout),
1005            ],
1006            immediate_size: 0,
1007        });
1008        let vertex_buffers = [
1009            Some(wgpu::VertexBufferLayout {
1010                array_stride: 8,
1011                step_mode: wgpu::VertexStepMode::Vertex,
1012                attributes: &[wgpu::VertexAttribute {
1013                    format: wgpu::VertexFormat::Float32x2,
1014                    offset: 0,
1015                    shader_location: 0,
1016                }],
1017            }),
1018            Some(wgpu::VertexBufferLayout {
1019                array_stride: size_of::<BatchInstance>() as u64,
1020                step_mode: wgpu::VertexStepMode::Instance,
1021                attributes: &[
1022                    wgpu::VertexAttribute {
1023                        format: wgpu::VertexFormat::Float32x4,
1024                        offset: 0,
1025                        shader_location: 2,
1026                    },
1027                    wgpu::VertexAttribute {
1028                        format: wgpu::VertexFormat::Float32x4,
1029                        offset: 16,
1030                        shader_location: 3,
1031                    },
1032                    wgpu::VertexAttribute {
1033                        format: wgpu::VertexFormat::Float32x2,
1034                        offset: 32,
1035                        shader_location: 4,
1036                    },
1037                    wgpu::VertexAttribute {
1038                        format: wgpu::VertexFormat::Float32x2,
1039                        offset: 40,
1040                        shader_location: 5,
1041                    },
1042                    wgpu::VertexAttribute {
1043                        format: wgpu::VertexFormat::Float32x4,
1044                        offset: 48,
1045                        shader_location: 6,
1046                    },
1047                    wgpu::VertexAttribute {
1048                        format: wgpu::VertexFormat::Float32,
1049                        offset: 64,
1050                        shader_location: 7,
1051                    },
1052                    wgpu::VertexAttribute {
1053                        format: wgpu::VertexFormat::Float32,
1054                        offset: 68,
1055                        shader_location: 8,
1056                    },
1057                    wgpu::VertexAttribute {
1058                        format: wgpu::VertexFormat::Uint32,
1059                        offset: 72,
1060                        shader_location: 9,
1061                    },
1062                    wgpu::VertexAttribute {
1063                        format: wgpu::VertexFormat::Float32,
1064                        offset: 76,
1065                        shader_location: 10,
1066                    },
1067                    wgpu::VertexAttribute {
1068                        format: wgpu::VertexFormat::Uint32,
1069                        offset: 80,
1070                        shader_location: 11,
1071                    },
1072                ],
1073            }),
1074        ];
1075        let mk_pipeline = |label: &'static str, blend: wgpu::BlendState| {
1076            device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1077                label: Some(label),
1078                layout: Some(&pipeline_layout),
1079                vertex: wgpu::VertexState {
1080                    module: &shader,
1081                    entry_point: Some("vs_main"),
1082                    buffers: &vertex_buffers,
1083                    compilation_options: Default::default(),
1084                },
1085                fragment: Some(wgpu::FragmentState {
1086                    module: &shader,
1087                    entry_point: Some("fs_main"),
1088                    targets: &[Some(wgpu::ColorTargetState {
1089                        format: screen.target_format,
1090                        blend: Some(blend),
1091                        write_mask: wgpu::ColorWrites::ALL,
1092                    })],
1093                    compilation_options: Default::default(),
1094                }),
1095                primitive: wgpu::PrimitiveState {
1096                    topology: wgpu::PrimitiveTopology::TriangleList,
1097                    ..Default::default()
1098                },
1099                depth_stencil: Some(wgpu::DepthStencilState {
1100                    format: wgpu::TextureFormat::Depth24PlusStencil8,
1101                    depth_write_enabled: Some(false),
1102                    depth_compare: Some(wgpu::CompareFunction::Always),
1103                    stencil: wgpu::StencilState {
1104                        front: wgpu::StencilFaceState {
1105                            compare: wgpu::CompareFunction::LessEqual,
1106                            ..Default::default()
1107                        },
1108                        back: wgpu::StencilFaceState {
1109                            compare: wgpu::CompareFunction::LessEqual,
1110                            ..Default::default()
1111                        },
1112                        ..Default::default()
1113                    },
1114                    bias: wgpu::DepthBiasState::default(),
1115                }),
1116                multisample: wgpu::MultisampleState {
1117                    count: screen.sample_count,
1118                    mask: !0,
1119                    alpha_to_coverage_enabled: false,
1120                },
1121                multiview_mask: None,
1122                cache: None,
1123            })
1124        };
1125        let pipeline_alpha = mk_pipeline("sprite_batch_pipeline", wgpu::BlendState::ALPHA_BLENDING);
1126        let pipeline_multiply = mk_pipeline(
1127            "sprite_batch_pipeline_mul",
1128            wgpu::BlendState {
1129                color: wgpu::BlendComponent {
1130                    src_factor: wgpu::BlendFactor::Dst,
1131                    dst_factor: wgpu::BlendFactor::Zero,
1132                    operation: wgpu::BlendOperation::Add,
1133                },
1134                alpha: wgpu::BlendComponent::OVER,
1135            },
1136        );
1137        let pipeline_additive = mk_pipeline(
1138            "sprite_batch_pipeline_add",
1139            wgpu::BlendState {
1140                color: wgpu::BlendComponent {
1141                    src_factor: wgpu::BlendFactor::SrcAlpha,
1142                    dst_factor: wgpu::BlendFactor::One,
1143                    operation: wgpu::BlendOperation::Add,
1144                },
1145                alpha: wgpu::BlendComponent {
1146                    src_factor: wgpu::BlendFactor::One,
1147                    dst_factor: wgpu::BlendFactor::One,
1148                    operation: wgpu::BlendOperation::Add,
1149                },
1150            },
1151        );
1152        let entry = BatchEntry {
1153            key,
1154            pipeline_alpha,
1155            pipeline_multiply,
1156            pipeline_additive,
1157            corners,
1158            instances,
1159            instance_cap: self.instances.len().max(1),
1160            last_alpha: 0,
1161            last_multiply_end: 0,
1162            last_total: 0,
1163            camera,
1164            cam_bind,
1165            atlas,
1166            atlas_key,
1167            light_uniform,
1168            light_bind,
1169            light_key: None,
1170            light_starved: false,
1171        };
1172        resources
1173            .get_mut::<BatchResources>()
1174            .expect("batch resources initialized")
1175            .batches
1176            .insert(self.id.clone(), entry);
1177        resources
1178            .get_mut::<BatchResources>()
1179            .expect("batch resources initialized")
1180            .prune_atlases();
1181    }
1182
1183    /// Record this batch's instance counts after uploading. Split out so
1184    /// sibling payloads (e.g. viewport views rebuilding the batch per
1185    /// frame) can share one prepared pipeline.
1186    fn finish_prepare(
1187        &self,
1188        alpha: u32,
1189        multiply_end: u32,
1190        total: u32,
1191        resources: &mut CallbackResources,
1192    ) {
1193        if let Some(all) = resources.get_mut::<BatchResources>()
1194            && let Some(res) = all.batches.get_mut(self.id.as_str())
1195        {
1196            res.last_alpha = alpha;
1197            res.last_multiply_end = multiply_end;
1198            res.last_total = total;
1199        }
1200    }
1201
1202    /// Point this batch at the light map the renderer's `finish_prepare` hook
1203    /// makes visible after every `prepare` has run, so scene order between a
1204    /// light pass and its consumers does not matter.
1205    ///
1206    /// Runs on the renderer's `finish_prepare`; also safe to call directly.
1207    pub fn resolve_lightmap(
1208        &self,
1209        device: &wgpu::Device,
1210        queue: &wgpu::Queue,
1211        resources: &mut CallbackResources,
1212    ) {
1213        let source = self.light_source.as_deref();
1214        let info = source.and_then(|id| super::lightpass::lookup_lightmap(resources, id));
1215        let Some(all) = resources.get_mut::<BatchResources>() else {
1216            return;
1217        };
1218        let Some(res) = all.batches.get_mut(self.id.as_str()) else {
1219            return;
1220        };
1221        if let Some(id) = source
1222            && info.is_none()
1223            && !res.light_starved
1224        {
1225            res.light_starved = true;
1226            log::warn!(
1227                "sprite_batch[{}]: no light map published by pass '{id}'; drawing unlit. \
1228                 The pass must be in the same scene and render into the same target as this batch.",
1229                self.id
1230            );
1231        }
1232        if info.is_some() {
1233            res.light_starved = false;
1234        }
1235        let key = match (&info, source) {
1236            (Some(info), Some(id)) => Some((id.to_string(), info.generation)),
1237            _ => None,
1238        };
1239        if res.light_key != key {
1240            res.light_key = key;
1241            res.light_bind = match &info {
1242                Some(info) => Arc::new(device.create_bind_group(&wgpu::BindGroupDescriptor {
1243                    label: Some("sprite_batch_light_bg"),
1244                    layout: &all.light_layout,
1245                    entries: &[
1246                        wgpu::BindGroupEntry {
1247                            binding: 0,
1248                            resource: wgpu::BindingResource::TextureView(&info.view),
1249                        },
1250                        wgpu::BindGroupEntry {
1251                            binding: 1,
1252                            resource: wgpu::BindingResource::Sampler(&all.light_sampler),
1253                        },
1254                        wgpu::BindGroupEntry {
1255                            binding: 2,
1256                            resource: res.light_uniform.as_entire_binding(),
1257                        },
1258                    ],
1259                })),
1260                None => all.identity_bind.clone(),
1261            };
1262        }
1263        // The world rect moves with the camera, so the uv transform is written
1264        // every frame; the bind group only changes when the map is recreated.
1265        let uniform = match &info {
1266            Some(info) => LightmapUniform {
1267                xform: [
1268                    1.0 / info.world_rect[2],
1269                    1.0 / info.world_rect[3],
1270                    -info.world_rect[0] / info.world_rect[2],
1271                    -info.world_rect[1] / info.world_rect[3],
1272                ],
1273                modulate: [
1274                    self.light_modulate[0],
1275                    self.light_modulate[1],
1276                    self.light_modulate[2],
1277                    0.0,
1278                ],
1279                layer: [info.layer_clamp, 0, 0, 0],
1280            },
1281            None => LightmapUniform {
1282                xform: [0.0; 4],
1283                modulate: [1.0, 1.0, 1.0, 0.0],
1284                layer: [0; 4],
1285            },
1286        };
1287        queue.write_buffer(&res.light_uniform, 0, bytemuck::bytes_of(&uniform));
1288    }
1289
1290    fn blend_group(flags: u32) -> u32 {
1291        match flags {
1292            1 => 2, // additive last
1293            2 => 1, // multiply middle
1294            _ => 0, // alpha first
1295        }
1296    }
1297
1298    /// Painter's order within one blend range: `z` ascending, with the
1299    /// push index breaking ties (stable for the producer's draw order).
1300    /// Across ranges the blend group dominates: all alpha draws before any
1301    /// multiply, all multiply before any additive, regardless of `z` or
1302    /// push order.
1303    /// `page` must NEVER participate: atlas page is a packing accident,
1304    /// and sorting on it reorders same-z sprites (bandit guns under
1305    /// bodies, bullet layers misaligned, camp floors over the vortex).
1306    fn sorted_instances(&self) -> (Vec<BatchInstance>, u32, u32) {
1307        let mut v: Vec<(usize, BatchInstance)> =
1308            self.instances.iter().copied().enumerate().collect();
1309        v.sort_by(|(ia, a), (ib, b)| {
1310            Self::blend_group(a.flags)
1311                .cmp(&Self::blend_group(b.flags))
1312                .then_with(|| a.z.total_cmp(&b.z).then_with(|| ia.cmp(ib)))
1313        });
1314        let v: Vec<BatchInstance> = v.into_iter().map(|(_, inst)| inst).collect();
1315        let alpha = v
1316            .iter()
1317            .take_while(|i| Self::blend_group(i.flags) == 0)
1318            .count() as u32;
1319        let multiply_end = alpha
1320            + v[alpha as usize..]
1321                .iter()
1322                .take_while(|i| Self::blend_group(i.flags) == 1)
1323                .count() as u32;
1324        (v, alpha, multiply_end)
1325    }
1326
1327    /// Game-owned passes can drive this batch into their OWN
1328    /// render target instead.
1329    pub fn prepare_with_uploads(
1330        &self,
1331        device: &wgpu::Device,
1332        queue: &wgpu::Queue,
1333        _encoder: &mut wgpu::CommandEncoder,
1334        screen: &ScreenDescriptor,
1335        resources: &mut CallbackResources,
1336        uploads: &[AtlasUpload],
1337    ) -> Vec<wgpu::CommandBuffer> {
1338        self.ensure_resources(device, queue, screen, resources);
1339        let (sorted, alpha, multiply_end) = self.sorted_instances();
1340        let total = sorted.len() as u32;
1341        let Some(all) = resources.get_mut::<BatchResources>() else {
1342            return Vec::new();
1343        };
1344        let Some(res) = all.batches.get_mut(self.id.as_str()) else {
1345            return Vec::new();
1346        };
1347
1348        if sorted.len() > res.instance_cap {
1349            let new_cap = sorted.len().next_power_of_two().max(64);
1350            res.instances = device.create_buffer(&wgpu::BufferDescriptor {
1351                label: Some("sprite_batch_instances"),
1352                size: (new_cap * size_of::<BatchInstance>()) as u64,
1353                usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
1354                mapped_at_creation: false,
1355            });
1356            res.instance_cap = new_cap;
1357        }
1358        queue.write_buffer(
1359            &res.camera,
1360            0,
1361            bytemuck::cast_slice(&[CameraUniform {
1362                view_proj: self.camera,
1363            }]),
1364        );
1365        if !sorted.is_empty() {
1366            queue.write_buffer(&res.instances, 0, bytemuck::cast_slice(&sorted));
1367        }
1368        let atlas = res.atlas.clone();
1369        // The atlas trails `desc.uploads_gen` until the generation's uploads
1370        // have been written, and it cannot be drawn from before then.
1371        let atlas_behind = atlas
1372            .applied_gen
1373            .lock()
1374            .map(|generation| *generation != Some(self.desc.uploads_gen))
1375            .unwrap_or(true);
1376        if atlas_behind && uploads.is_empty() {
1377            self.report_atlas_starved(&atlas);
1378            self.finish_prepare(alpha, multiply_end, total, resources);
1379            return Vec::new();
1380        }
1381        let mut wrote_any = false;
1382        for up in uploads {
1383            if !atlas_behind {
1384                continue;
1385            }
1386            let expected = up.w as usize * up.h as usize * 4;
1387            if up.page >= self.desc.layers
1388                || up.x + up.w > self.desc.layer_size
1389                || up.y + up.h > self.desc.layer_size
1390                || up.rgba.len() != expected
1391            {
1392                log::warn!(
1393                    "sprite_batch: dropping out-of-range upload page={} {}x{}+{}+{} ({} bytes)",
1394                    up.page,
1395                    up.w,
1396                    up.h,
1397                    up.x,
1398                    up.y,
1399                    up.rgba.len()
1400                );
1401                continue;
1402            }
1403            queue.write_texture(
1404                wgpu::TexelCopyTextureInfo {
1405                    texture: &atlas.texture,
1406                    mip_level: 0,
1407                    origin: wgpu::Origin3d {
1408                        x: up.x,
1409                        y: up.y,
1410                        z: up.page,
1411                    },
1412                    aspect: wgpu::TextureAspect::All,
1413                },
1414                &up.rgba,
1415                wgpu::TexelCopyBufferLayout {
1416                    offset: 0,
1417                    bytes_per_row: Some(up.w * 4),
1418                    rows_per_image: Some(up.h),
1419                },
1420                wgpu::Extent3d {
1421                    width: up.w,
1422                    height: up.h,
1423                    depth_or_array_layers: 1,
1424                },
1425            );
1426            wrote_any = true;
1427        }
1428        if atlas_behind
1429            && wrote_any
1430            && let Ok(mut generation) = atlas.applied_gen.lock()
1431        {
1432            *generation = Some(self.desc.uploads_gen);
1433        }
1434        self.finish_prepare(alpha, multiply_end, total, resources);
1435        Vec::new()
1436    }
1437
1438    /// The atlas trails its descriptor and nothing is queued to fill it, so the
1439    /// batch draws nothing at all.
1440    fn report_atlas_starved(&self, atlas: &AtlasResources) {
1441        let Ok(mut reported) = atlas.starved_gen.lock() else {
1442            return;
1443        };
1444        if *reported == Some(self.desc.uploads_gen) {
1445            return;
1446        }
1447        *reported = Some(self.desc.uploads_gen);
1448        log::error!(
1449            "sprite_batch[{}]: atlas generation {} is unfilled and no uploads arrived, \
1450             drawing nothing; pass that generation's uploads on every frame until \
1451             the batch applies them, because the first frame carrying them may be \
1452             built before the view is prepared and then discarded",
1453            self.id,
1454            self.desc.uploads_gen
1455        );
1456    }
1457}
1458
1459trait BatchRenderPass {
1460    fn set_bind_group(&mut self, index: u32, bind_group: &wgpu::BindGroup, offsets: &[u32]);
1461    fn set_vertex_buffer(&mut self, slot: u32, buffer: wgpu::BufferSlice<'_>);
1462    fn set_pipeline(&mut self, pipeline: &wgpu::RenderPipeline);
1463    fn draw(&mut self, vertices: std::ops::Range<u32>, instances: std::ops::Range<u32>);
1464}
1465
1466impl BatchRenderPass for wgpu::RenderPass<'_> {
1467    fn set_bind_group(&mut self, index: u32, bind_group: &wgpu::BindGroup, offsets: &[u32]) {
1468        self.set_bind_group(index, bind_group, offsets);
1469    }
1470
1471    fn set_vertex_buffer(&mut self, slot: u32, buffer: wgpu::BufferSlice<'_>) {
1472        self.set_vertex_buffer(slot, buffer);
1473    }
1474
1475    fn set_pipeline(&mut self, pipeline: &wgpu::RenderPipeline) {
1476        self.set_pipeline(pipeline);
1477    }
1478
1479    fn draw(&mut self, vertices: std::ops::Range<u32>, instances: std::ops::Range<u32>) {
1480        self.draw(vertices, instances);
1481    }
1482}
1483
1484impl BatchRenderPass for CallbackRenderPass<'_, '_> {
1485    fn set_bind_group(&mut self, index: u32, bind_group: &wgpu::BindGroup, offsets: &[u32]) {
1486        self.set_bind_group(index, bind_group, offsets);
1487    }
1488
1489    fn set_vertex_buffer(&mut self, slot: u32, buffer: wgpu::BufferSlice<'_>) {
1490        self.set_vertex_buffer(slot, buffer);
1491    }
1492
1493    fn set_pipeline(&mut self, pipeline: &wgpu::RenderPipeline) {
1494        self.set_pipeline(pipeline);
1495    }
1496
1497    fn draw(&mut self, vertices: std::ops::Range<u32>, instances: std::ops::Range<u32>) {
1498        self.draw(vertices, instances);
1499    }
1500}
1501
1502fn draw_batch_with_pass<P: BatchRenderPass>(
1503    id: &str,
1504    rpass: &mut P,
1505    resources: &CallbackResources,
1506) {
1507    let Some(all) = resources.get::<BatchResources>() else {
1508        return;
1509    };
1510    let Some(res) = all.batches.get(id) else {
1511        return;
1512    };
1513    if res.last_total == 0 {
1514        return;
1515    }
1516    rpass.set_bind_group(0, &res.cam_bind, &[]);
1517    rpass.set_bind_group(1, &res.atlas.tex_bind, &[]);
1518    rpass.set_bind_group(2, res.light_bind.as_ref(), &[]);
1519    rpass.set_vertex_buffer(0, res.corners.slice(..));
1520    rpass.set_vertex_buffer(1, res.instances.slice(..));
1521    if res.last_alpha > 0 {
1522        rpass.set_pipeline(&res.pipeline_alpha);
1523        rpass.draw(0..6, 0..res.last_alpha);
1524    }
1525    if res.last_multiply_end > res.last_alpha {
1526        rpass.set_pipeline(&res.pipeline_multiply);
1527        rpass.draw(0..6, res.last_alpha..res.last_multiply_end);
1528    }
1529    if res.last_total > res.last_multiply_end {
1530        rpass.set_pipeline(&res.pipeline_additive);
1531        rpass.draw(0..6, res.last_multiply_end..res.last_total);
1532    }
1533}
1534
1535/// Draw the prepared batch for one id. Shared by [`SpriteBatch`] and
1536/// viewport payloads so all GPU consumers issue identical draw calls.
1537pub fn draw_batch_with_id(
1538    id: &str,
1539    rpass: &mut wgpu::RenderPass<'_>,
1540    resources: &CallbackResources,
1541) {
1542    draw_batch_with_pass(id, rpass, resources);
1543}
1544
1545pub(crate) fn draw_batch_with_id_callback(
1546    id: &str,
1547    rpass: &mut CallbackRenderPass<'_, '_>,
1548    resources: &CallbackResources,
1549) {
1550    draw_batch_with_pass(id, rpass, resources);
1551}
1552
1553/// Draw the default batch (backward compat for single-batch apps).
1554pub fn draw_batch(rpass: &mut wgpu::RenderPass<'_>, resources: &CallbackResources) {
1555    draw_batch_with_id("sprite_batch.default", rpass, resources);
1556}
1557
1558impl WgpuCallback for SpriteBatch {
1559    fn resource_key(&self) -> Option<&str> {
1560        Some(super::lightpass::SHARED_CALLBACK_KEY)
1561    }
1562
1563    fn resource_type_id(&self) -> std::any::TypeId {
1564        std::any::TypeId::of::<super::lightpass::LightScope>()
1565    }
1566
1567    fn prepare(
1568        &self,
1569        device: &wgpu::Device,
1570        queue: &wgpu::Queue,
1571        encoder: &mut wgpu::CommandEncoder,
1572        screen: &ScreenDescriptor,
1573        resources: &mut CallbackResources,
1574    ) -> Vec<wgpu::CommandBuffer> {
1575        self.prepare_with_uploads(device, queue, encoder, screen, resources, &self.uploads)
1576    }
1577
1578    fn finish_prepare(
1579        &self,
1580        device: &wgpu::Device,
1581        queue: &wgpu::Queue,
1582        _encoder: &mut wgpu::CommandEncoder,
1583        _screen: &ScreenDescriptor,
1584        resources: &mut CallbackResources,
1585    ) -> Vec<wgpu::CommandBuffer> {
1586        self.resolve_lightmap(device, queue, resources);
1587        Vec::new()
1588    }
1589
1590    fn paint(
1591        &self,
1592        _info: repose_core::PaintCallbackInfo,
1593        rpass: &mut CallbackRenderPass<'_, '_>,
1594        resources: &CallbackResources,
1595    ) {
1596        draw_batch_with_id_callback(self.id.as_str(), rpass, resources);
1597    }
1598}
1599
1600/// One view's worth of atlas draws: the sprite rows for this frame plus the
1601/// shared atlas uploads. Each scene node owns its frame, so several views can
1602/// batch from one atlas without sharing (and clobbering) one batch; the pixel
1603/// data itself rides an `Arc` and is never cloned.
1604pub struct BatchFrame {
1605    batch: SpriteBatch,
1606    uploads: std::sync::Arc<[AtlasUpload]>,
1607}
1608
1609impl BatchFrame {
1610    pub fn new(batch: SpriteBatch, uploads: std::sync::Arc<[AtlasUpload]>) -> Self {
1611        Self { batch, uploads }
1612    }
1613
1614    pub fn node(self, rect: repose_core::Rect) -> repose_core::SceneNode {
1615        repose_core::SceneNode::Callback {
1616            rect,
1617            payload: repose_render_wgpu::Callback::new(self),
1618        }
1619    }
1620}
1621
1622impl WgpuCallback for BatchFrame {
1623    fn resource_key(&self) -> Option<&str> {
1624        Some(super::lightpass::SHARED_CALLBACK_KEY)
1625    }
1626
1627    fn resource_type_id(&self) -> std::any::TypeId {
1628        std::any::TypeId::of::<super::lightpass::LightScope>()
1629    }
1630
1631    fn prepare(
1632        &self,
1633        device: &wgpu::Device,
1634        queue: &wgpu::Queue,
1635        encoder: &mut wgpu::CommandEncoder,
1636        screen: &ScreenDescriptor,
1637        resources: &mut CallbackResources,
1638    ) -> Vec<wgpu::CommandBuffer> {
1639        self.batch
1640            .prepare_with_uploads(device, queue, encoder, screen, resources, &self.uploads)
1641    }
1642
1643    fn finish_prepare(
1644        &self,
1645        device: &wgpu::Device,
1646        queue: &wgpu::Queue,
1647        _encoder: &mut wgpu::CommandEncoder,
1648        _screen: &ScreenDescriptor,
1649        resources: &mut CallbackResources,
1650    ) -> Vec<wgpu::CommandBuffer> {
1651        self.batch.resolve_lightmap(device, queue, resources);
1652        Vec::new()
1653    }
1654
1655    fn paint(
1656        &self,
1657        _info: repose_core::PaintCallbackInfo,
1658        rpass: &mut CallbackRenderPass<'_, '_>,
1659        resources: &CallbackResources,
1660    ) {
1661        draw_batch_with_id_callback(&self.batch.id, rpass, resources);
1662    }
1663}
1664
1665#[cfg(test)]
1666mod tests {
1667    use super::*;
1668
1669    fn apply(rows: ([f32; 4], [f32; 4]), corner: [f32; 2]) -> [f32; 2] {
1670        let (r0, r1) = rows;
1671        [
1672            r0[0] * corner[0] + r0[1] * corner[1] + r0[3],
1673            r1[0] * corner[0] + r1[1] * corner[1] + r1[3],
1674        ]
1675    }
1676
1677    #[test]
1678    fn default_rows_match_canvas_top_left() {
1679        let rows = instance_rows([400.0, 300.0], [64.0, 80.0], 0.0, [0.5, 0.5], false, false);
1680        let tl = apply(rows, [-0.5, -0.5]);
1681        let br = apply(rows, [0.5, 0.5]);
1682        assert_eq!(tl, [368.0, 260.0]);
1683        assert_eq!(br, [432.0, 340.0]);
1684    }
1685
1686    #[test]
1687    fn frame_uv_indexes_row_major() {
1688        // 4x2 sheet: frame 5 is column 1, row 1.
1689        let (mn, mx) = frame_uv(4, 2, 5);
1690        assert_eq!(mn, [0.25, 0.5]);
1691        assert_eq!(mx, [0.5, 1.0]);
1692        // First cell starts at the origin; clamping holds the last cell.
1693        assert_eq!(frame_uv(4, 2, 0).0, [0.0, 0.0]);
1694        assert_eq!(frame_uv(4, 2, 99), frame_uv(4, 2, 7));
1695        assert_eq!(frame_uv(0, 0, 3), ([0.0, 0.0], [1.0, 1.0]));
1696    }
1697
1698    #[test]
1699    fn sprite_aabb_matches_unrotated_quad() {
1700        let (mn, mx) = sprite_aabb([400.0, 300.0], [64.0, 80.0], 0.0, [0.5, 0.5], false, false);
1701        assert_eq!(mn, [368.0, 260.0]);
1702        assert_eq!(mx, [432.0, 340.0]);
1703        // A quarter turn swaps the footprint: 64x80 becomes 80x64.
1704        let (mn, mx) = sprite_aabb(
1705            [0.0, 0.0],
1706            [64.0, 80.0],
1707            std::f32::consts::FRAC_PI_2,
1708            [0.5, 0.5],
1709            false,
1710            false,
1711        );
1712        assert!((mn[0] + 40.0).abs() < 1e-4 && (mn[1] + 32.0).abs() < 1e-4);
1713        assert!((mx[0] - 40.0).abs() < 1e-4 && (mx[1] - 32.0).abs() < 1e-4);
1714    }
1715
1716    #[test]
1717    fn quarter_turn_rotates_footprint() {
1718        let rows = instance_rows(
1719            [0.0, 0.0],
1720            [64.0, 80.0],
1721            std::f32::consts::FRAC_PI_2,
1722            [0.5, 0.5],
1723            false,
1724            false,
1725        );
1726        // Local top-left (-32,-40) rotates to (40,-32) in y-down space.
1727        let p = apply(rows, [-0.5, -0.5]);
1728        assert!(
1729            (p[0] - 40.0).abs() < 1e-4 && (p[1] + 32.0).abs() < 1e-4,
1730            "got {p:?}"
1731        );
1732    }
1733
1734    #[test]
1735    fn top_left_anchor_pins_corner() {
1736        let rows = instance_rows([100.0, 100.0], [64.0, 80.0], 0.0, [0.0, 0.0], false, false);
1737        let tl = apply(rows, [-0.5, -0.5]);
1738        assert_eq!(tl, [100.0, 100.0]);
1739    }
1740
1741    #[test]
1742    fn flip_x_mirrors_around_anchor() {
1743        let plain = instance_rows([0.0, 0.0], [64.0, 8.0], 0.0, [0.5, 0.5], false, false);
1744        let flipped = instance_rows([0.0, 0.0], [64.0, 8.0], 0.0, [0.5, 0.5], true, false);
1745        let a = apply(plain, [-0.5, 0.0]);
1746        let b = apply(flipped, [-0.5, 0.0]);
1747        assert_eq!(a, [-32.0, 0.0]);
1748        assert_eq!(b, [32.0, 0.0]);
1749    }
1750
1751    #[test]
1752    fn screen_camera_is_y_down() {
1753        let cam = screen_camera([800.0, 600.0]);
1754        let top_left = cam.project_point3(glam::Vec3::new(0.0, 0.0, 0.0));
1755        let bottom_right = cam.project_point3(glam::Vec3::new(800.0, 600.0, 0.0));
1756        let center = cam.project_point3(glam::Vec3::new(400.0, 300.0, 0.0));
1757        // NDC y-up: screen top is +1, screen bottom is -1.
1758        assert!((top_left.x + 1.0).abs() < 1e-5 && (top_left.y - 1.0).abs() < 1e-5);
1759        assert!((bottom_right.x - 1.0).abs() < 1e-5 && (bottom_right.y + 1.0).abs() < 1e-5);
1760        assert!(center.x.abs() < 1e-5 && center.y.abs() < 1e-5);
1761    }
1762
1763    #[test]
1764    fn push_sprite_bridges_snapshot_fields() {
1765        let mut batch = SpriteBatch::new(BatchDesc::default());
1766        batch.push_sprite(&SpriteInstance {
1767            center: glam::Vec2::new(10.0, 20.0),
1768            size: glam::Vec2::new(4.0, 6.0),
1769            uv_min: glam::Vec2::new(0.25, 0.5),
1770            uv_max: glam::Vec2::new(0.5, 0.75),
1771            color: [1.0, 0.5, 0.25, 0.8],
1772            page: 3,
1773            ..Default::default()
1774        });
1775        assert_eq!(batch.len(), 1);
1776        let inst = batch.instances[0];
1777        assert_eq!(inst.uv_min, [0.25, 0.5]);
1778        assert_eq!(inst.uv_max, [0.5, 0.75]);
1779        // The snapshot tint is display-referred; the batch linearizes it.
1780        assert_eq!(
1781            inst.tint,
1782            crate::rgba8([1.0, 0.5, 0.25, 0.8]).to_linear(),
1783            "tint reaches the batch in linear space"
1784        );
1785        assert_eq!(inst.page, 3.0);
1786        // Default anchor centers: top-left at (8, 17).
1787        let p = apply((inst.row0, inst.row1), [-0.5, -0.5]);
1788        assert_eq!(p, [8.0, 17.0]);
1789        assert_eq!(inst.light_mask, 1, "sprites default to light layer 0");
1790    }
1791
1792    #[test]
1793    fn z_and_blend_sort_into_ranges() {
1794        use super::super::SpriteBlend;
1795        let mut batch = SpriteBatch::with_id("test.sort", BatchDesc::default());
1796        let quad = |z: f32, blend: SpriteBlend| SpriteInstance {
1797            center: glam::Vec2::new(0.0, 0.0),
1798            size: glam::Vec2::new(1.0, 1.0),
1799            z,
1800            blend,
1801            ..Default::default()
1802        };
1803        batch.push_sprite(&quad(5.0, SpriteBlend::Alpha));
1804        batch.push_sprite(&quad(1.0, SpriteBlend::Additive));
1805        batch.push_sprite(&quad(3.0, SpriteBlend::Multiply));
1806        batch.push_sprite(&quad(2.0, SpriteBlend::Alpha));
1807        batch.push_sprite(&quad(0.0, SpriteBlend::Multiply));
1808        let (sorted, alpha, mul_end) = batch.sorted_instances();
1809        assert_eq!((alpha, mul_end, sorted.len() as u32), (2, 4, 5));
1810        assert_eq!([sorted[0].z, sorted[1].z], [2.0, 5.0]);
1811        assert_eq!([sorted[2].z, sorted[3].z], [0.0, 3.0]);
1812        assert_eq!(sorted[4].z, 1.0);
1813        assert_eq!(sorted[4].flags, 1);
1814    }
1815
1816    /// Atlas page is a packing accident, not a draw key: same-z
1817    /// same-blend sprites keep push order regardless of page (bandit
1818    /// guns over bodies, bullet layers aligned, camp floors under the
1819    /// vortex — all same-z pairs the old page tiebreak reordered).
1820    #[test]
1821    fn same_z_keeps_push_order_across_pages() {
1822        use super::super::SpriteBlend;
1823        let mut batch = SpriteBatch::with_id("test.pages", BatchDesc::default());
1824        let quad = |page: u32| SpriteInstance {
1825            center: glam::Vec2::new(0.0, 0.0),
1826            size: glam::Vec2::new(1.0, 1.0),
1827            z: 0.0,
1828            blend: SpriteBlend::Alpha,
1829            page,
1830            ..Default::default()
1831        };
1832        batch.push_sprite(&quad(1));
1833        batch.push_sprite(&quad(0));
1834        let (sorted, _, _) = batch.sorted_instances();
1835        assert_eq!([sorted[0].page, sorted[1].page], [1.0, 0.0]);
1836    }
1837
1838    #[test]
1839    fn white_upload_is_1x1_white() {
1840        let up = AtlasUpload::white(2, 5, 7);
1841        assert_eq!((up.page, up.x, up.y, up.w, up.h), (2, 5, 7, 1, 1));
1842        assert_eq!(up.rgba, vec![255, 255, 255, 255]);
1843    }
1844
1845    #[test]
1846    fn single_layer_atlas_draws_tinted_sprites() {
1847        // A one-layer atlas must be a plain D2 texture, not a one-element
1848        // D2Array: GL/ANGLE assumes D2 for it and then samples the array view
1849        // as black, which silently erases every sprite with no validation
1850        // error. Guards the shape choice for the layout, the view and the
1851        // shader together, since they only agree as a set.
1852        assert_eq!(
1853            crate::batch::atlas_shape(1),
1854            (wgpu::TextureViewDimension::D2, false)
1855        );
1856        assert_eq!(
1857            crate::batch::atlas_shape(4),
1858            (wgpu::TextureViewDimension::D2Array, true)
1859        );
1860        assert!(
1861            crate::batch::sprite_shader(1)
1862                .contains("@group(1) @binding(0) var atlas: texture_2d<f32>")
1863                && !crate::batch::sprite_shader(1)
1864                    .contains("@group(1) @binding(0) var atlas: texture_2d_array"),
1865            "single-layer shader samples a 2d atlas"
1866        );
1867        assert!(
1868            crate::batch::sprite_shader(4)
1869                .contains("@group(1) @binding(0) var atlas: texture_2d_array"),
1870            "multi-layer shader samples an array atlas"
1871        );
1872        // The light map is an array in both variants; a one-layer light map
1873        // would hit the same GL/ANGLE trap, so batches always allocate two.
1874        assert!(
1875            crate::batch::sprite_shader(1)
1876                .contains("@group(2) @binding(0) var lightmap: texture_2d_array<f32>"),
1877            "the light map is sampled as an array regardless of atlas shape"
1878        );
1879    }
1880
1881    /// End-to-end GPU proof: upload atlas pages, draw textured quads
1882    /// offscreen, and assert exact pixels (positions, colors, page,
1883    /// mirror). sRGB primaries are transfer-function fixed points, so
1884    /// asserts are exact. Skips gracefully where no GPU exists.
1885    #[test]
1886    fn offscreen_batch_draws_oriented_textured_quads() {
1887        use repose_core::{Color, Rect, Scene, SceneNode};
1888        use repose_render_wgpu::{Callback, offscreen::OffscreenRenderer};
1889
1890        let mut renderer = match OffscreenRenderer::new_blocking(256, 256, 1) {
1891            Ok(r) => r,
1892            Err(e) => {
1893                eprintln!("SKIP offscreen batch test (no GPU): {e}");
1894                return;
1895            }
1896        };
1897        // Page 0 (2x2, row-major top first): R G / B W.
1898        let page0 = vec![
1899            255, 0, 0, 255, //
1900            0, 255, 0, 255, //
1901            0, 0, 255, 255, //
1902            255, 255, 255, 255,
1903        ];
1904        // Page 1: solid magenta.
1905        let page1 = [255, 0, 255, 255].repeat(4);
1906        let mut batch = SpriteBatch::new(BatchDesc {
1907            layer_size: 2,
1908            layers: 2,
1909            filter: TextureFilter::Nearest,
1910            ..Default::default()
1911        });
1912        batch.upload(AtlasUpload {
1913            page: 0,
1914            x: 0,
1915            y: 0,
1916            w: 2,
1917            h: 2,
1918            rgba: page0,
1919        });
1920        batch.upload(AtlasUpload {
1921            page: 1,
1922            x: 0,
1923            y: 0,
1924            w: 2,
1925            h: 2,
1926            rgba: page1,
1927        });
1928        batch.set_camera(screen_camera([256.0, 256.0]));
1929        // Big quad over the middle sampling page 0 fully.
1930        batch.push(
1931            [128.0, 128.0],
1932            [128.0, 128.0],
1933            0.0,
1934            [0.5, 0.5],
1935            false,
1936            false,
1937            [0.0, 0.0],
1938            [1.0, 1.0],
1939            [1.0, 1.0, 1.0, 1.0],
1940            0,
1941        );
1942        // Small quad bottom-right sampling page 1.
1943        batch.push(
1944            [224.0, 224.0],
1945            [32.0, 32.0],
1946            0.0,
1947            [0.5, 0.5],
1948            false,
1949            false,
1950            [0.0, 0.0],
1951            [1.0, 1.0],
1952            [1.0, 1.0, 1.0, 1.0],
1953            1,
1954        );
1955        let scene = Scene {
1956            clear_color: Color::from_rgba(0, 0, 0, 255),
1957            nodes: vec![SceneNode::Callback {
1958                rect: Rect {
1959                    x: 0.0,
1960                    y: 0.0,
1961                    w: 256.0,
1962                    h: 256.0,
1963                },
1964                payload: Callback::new(batch),
1965            }],
1966        };
1967        let px = renderer
1968            .render_rgba(&scene, Some([0.0, 0.0, 0.0, 1.0]))
1969            .expect("offscreen render");
1970        let at = |x: u32, y: u32| -> [u8; 4] {
1971            let i = ((y * 256 + x) * 4) as usize;
1972            [px[i], px[i + 1], px[i + 2], px[i + 3]]
1973        };
1974        // Orientation: uv (0,0) is the PNG top-left.
1975        assert_eq!(at(64, 64), [255, 0, 0, 255], "top-left red");
1976        assert_eq!(at(191, 64), [0, 255, 0, 255], "top-right green");
1977        assert_eq!(at(64, 191), [0, 0, 255, 255], "bottom-left blue");
1978        assert_eq!(at(191, 191), [255, 255, 255, 255], "bottom-right white");
1979        assert_eq!(at(224, 224), [255, 0, 255, 255], "page 1 magenta");
1980        assert_eq!(at(8, 8), [0, 0, 0, 255], "background clear");
1981
1982        // Mirror check: same quad flipped samples the opposite column.
1983        let mut flipped = SpriteBatch::new(BatchDesc {
1984            layer_size: 2,
1985            layers: 2,
1986            filter: TextureFilter::Nearest,
1987            ..Default::default()
1988        });
1989        flipped.set_camera(screen_camera([256.0, 256.0]));
1990        flipped.push(
1991            [128.0, 128.0],
1992            [128.0, 128.0],
1993            0.0,
1994            [0.5, 0.5],
1995            true,
1996            false,
1997            [0.0, 0.0],
1998            [1.0, 1.0],
1999            [1.0, 1.0, 1.0, 1.0],
2000            0,
2001        );
2002        let scene = Scene {
2003            clear_color: Color::from_rgba(0, 0, 0, 255),
2004            nodes: vec![SceneNode::Callback {
2005                rect: Rect {
2006                    x: 0.0,
2007                    y: 0.0,
2008                    w: 256.0,
2009                    h: 256.0,
2010                },
2011                payload: Callback::new(flipped),
2012            }],
2013        };
2014        let px = renderer
2015            .render_rgba(&scene, Some([0.0, 0.0, 0.0, 1.0]))
2016            .expect("offscreen render");
2017        let at = |x: u32, y: u32| -> [u8; 4] {
2018            let i = ((y * 256 + x) * 4) as usize;
2019            [px[i], px[i + 1], px[i + 2], px[i + 3]]
2020        };
2021        assert_eq!(at(64, 64), [0, 255, 0, 255], "flipped top-left green");
2022        assert_eq!(at(191, 191), [0, 0, 255, 255], "flipped bottom-right blue");
2023    }
2024
2025    /// Sibling batch nodes with distinct ids draw independently. GPU
2026    /// resources are keyed by batch id, so two runs sharing one id would
2027    /// collide and only the last prepared run would paint; the unified
2028    /// producer gives every run its own id for exactly this reason.
2029    #[test]
2030    fn distinct_batch_ids_compose_in_one_scene() {
2031        use repose_core::{Color, Rect, Scene};
2032        use repose_render_wgpu::offscreen::OffscreenRenderer;
2033        use std::sync::Arc;
2034
2035        let mut renderer = match OffscreenRenderer::new_blocking(256, 256, 1) {
2036            Ok(r) => r,
2037            Err(e) => {
2038                eprintln!("SKIP batch compose test (no GPU): {e}");
2039                return;
2040            }
2041        };
2042        let page = vec![
2043            255, 0, 0, 255, //
2044            0, 255, 0, 255, //
2045            0, 0, 255, 255, //
2046            255, 255, 255, 255,
2047        ];
2048        let uploads: Arc<[AtlasUpload]> = Arc::from(vec![AtlasUpload {
2049            page: 0,
2050            x: 0,
2051            y: 0,
2052            w: 2,
2053            h: 2,
2054            rgba: page,
2055        }]);
2056        let desc = BatchDesc {
2057            layer_size: 2,
2058            layers: 1,
2059            filter: TextureFilter::Nearest,
2060            ..Default::default()
2061        };
2062        let mut left = SpriteBatch::with_id("test.compose.left", desc.clone());
2063        left.set_camera(screen_camera([256.0, 256.0]));
2064        left.push(
2065            [64.0, 128.0],
2066            [64.0, 64.0],
2067            0.0,
2068            [0.5, 0.5],
2069            false,
2070            false,
2071            [0.0, 0.0],
2072            [1.0, 1.0],
2073            [1.0, 1.0, 1.0, 1.0],
2074            0,
2075        );
2076        let mut right = SpriteBatch::with_id("test.compose.right", desc.clone());
2077        right.set_camera(screen_camera([256.0, 256.0]));
2078        right.push(
2079            [192.0, 128.0],
2080            [64.0, 64.0],
2081            0.0,
2082            [0.5, 0.5],
2083            false,
2084            false,
2085            [0.0, 0.0],
2086            [1.0, 1.0],
2087            [1.0, 1.0, 1.0, 1.0],
2088            0,
2089        );
2090        let full = Rect {
2091            x: 0.0,
2092            y: 0.0,
2093            w: 256.0,
2094            h: 256.0,
2095        };
2096        let scene = Scene {
2097            clear_color: Color::from_rgba(0, 0, 0, 255),
2098            nodes: vec![
2099                BatchFrame::new(left, uploads.clone()).node(full),
2100                BatchFrame::new(right, uploads.clone()).node(full),
2101            ],
2102        };
2103        let px = renderer
2104            .render_rgba(&scene, Some([0.0, 0.0, 0.0, 1.0]))
2105            .expect("offscreen render");
2106        let at = |x: u32, y: u32| -> [u8; 4] {
2107            let i = ((y * 256 + x) * 4) as usize;
2108            [px[i], px[i + 1], px[i + 2], px[i + 3]]
2109        };
2110        assert_eq!(at(40, 104), [255, 0, 0, 255], "left run red");
2111        assert_eq!(at(168, 104), [255, 0, 0, 255], "right run red");
2112    }
2113}