Skip to main content

repame_sprite/
batch.rs

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