Skip to main content

pite_render/
lib.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! 2D hardware-accelerated rendering subsystem for the Pite game engine.
3//!
4//! Defines the `Renderer2D` trait and provides a pure-Rust `wgpu` implementation
5//! supporting batched sprite quad rendering, texture atlas sampling, font
6//! rasterization, and viewport projections.
7
8use anyhow::{Context, Result};
9use wgpu::util::DeviceExt;
10
11pub mod atlas;
12pub mod text;
13
14pub use atlas::{Atlas, AtlasAnimation, AtlasFrame};
15
16const FULL_UV: [f32; 4] = [0.0, 0.0, 1.0, 1.0];
17
18pub trait Renderer2D {
19    fn begin_frame(&mut self) -> Result<()>;
20    fn draw_sprite(&mut self, texture: &str, x: f64, y: f64, rotation: f64) -> Result<()>;
21    /// Draw `texture`, optionally from one named atlas frame, rotated by
22    /// `rotation` radians around the sprite center. The default impl
23    /// forwards to `draw_sprite`, so impls without atlas support stay
24    /// correct.
25    fn draw_sprite_frame(
26        &mut self,
27        texture: &str,
28        frame: Option<&str>,
29        x: f64,
30        y: f64,
31        rotation: f64,
32    ) -> Result<()> {
33        let _ = frame;
34        self.draw_sprite(texture, x, y, rotation)
35    }
36    fn draw_text(&mut self, text: &str, x: f64, y: f64, size: f32, color: [u8; 4]) -> Result<()>;
37    fn draw_rect(&mut self, x: f64, y: f64, w: f64, h: f64, color: [u8; 4]) -> Result<()>;
38    fn end_frame(&mut self) -> Result<()>;
39    fn set_camera(&mut self, _x: f64, _y: f64, _zoom: f64) {}
40    fn reload_asset(&mut self, _path: &str) {}
41    fn clear_asset_cache(&mut self) {}
42}
43
44#[derive(Debug, Default)]
45pub struct NoopRenderer;
46
47impl Renderer2D for NoopRenderer {
48    fn begin_frame(&mut self) -> Result<()> {
49        Ok(())
50    }
51
52    fn draw_sprite(&mut self, _texture: &str, _x: f64, _y: f64, _rotation: f64) -> Result<()> {
53        Ok(())
54    }
55
56    fn draw_text(
57        &mut self,
58        _text: &str,
59        _x: f64,
60        _y: f64,
61        _size: f32,
62        _color: [u8; 4],
63    ) -> Result<()> {
64        Ok(())
65    }
66
67    fn draw_rect(&mut self, _x: f64, _y: f64, _w: f64, _h: f64, _color: [u8; 4]) -> Result<()> {
68        Ok(())
69    }
70
71    fn end_frame(&mut self) -> Result<()> {
72        Ok(())
73    }
74
75    fn reload_asset(&mut self, _path: &str) {}
76    fn clear_asset_cache(&mut self) {}
77}
78
79pub fn world_to_screen(
80    world: (f64, f64),
81    cam: (f64, f64),
82    zoom: f64,
83    size: (u32, u32),
84) -> (f32, f32) {
85    let sx = (size.0 as f64 / 2.0 + (world.0 - cam.0) * zoom) as f32;
86    let sy = (size.1 as f64 / 2.0 + (world.1 - cam.1) * zoom) as f32;
87    (sx, sy)
88}
89
90pub fn screen_to_world(
91    screen: (f32, f32),
92    cam: (f64, f64),
93    zoom: f64,
94    size: (u32, u32),
95) -> (f64, f64) {
96    let zoom = if zoom == 0.0 { 1.0 } else { zoom };
97    (
98        cam.0 + (screen.0 as f64 - size.0 as f64 / 2.0) / zoom,
99        cam.1 + (screen.1 as f64 - size.1 as f64 / 2.0) / zoom,
100    )
101}
102
103/// Uniform "contain" scale that fits `design` inside `actual` keeping aspect:
104/// the game renders the design resolution through `zoom * fit_scale` so
105/// content grows and shrinks with the window instead of showing more world.
106/// Zero sizes fall back to 1.0 (a minimized window must not divide).
107pub fn fit_scale(design: (u32, u32), actual: (u32, u32)) -> f64 {
108    if design.0 == 0 || design.1 == 0 || actual.0 == 0 || actual.1 == 0 {
109        return 1.0;
110    }
111    (actual.0 as f64 / design.0 as f64).min(actual.1 as f64 / design.1 as f64)
112}
113
114pub fn screen_to_ndc(screen: (f32, f32), size: (u32, u32)) -> (f32, f32) {
115    (
116        screen.0 / size.0 as f32 * 2.0 - 1.0,
117        1.0 - screen.1 / size.1 as f32 * 2.0,
118    )
119}
120
121#[repr(C)]
122#[derive(Clone, Copy, Debug, bytemuck::Pod, bytemuck::Zeroable)]
123struct SpriteVertex {
124    pos: [f32; 2],
125    uv: [f32; 2],
126}
127
128fn quad_for(
129    center_ndc: (f32, f32),
130    w_px: f32,
131    h_px: f32,
132    size: (u32, u32),
133    uv: [f32; 4],
134    rotation: f32,
135) -> [SpriteVertex; 6] {
136    // Rotation must be uniform in pixel space — rotating in NDC would
137    // shear for non-square viewports. Compute corners in pixels, rotate,
138    // then convert to NDC.
139    let cx_px = (center_ndc.0 + 1.0) * size.0 as f32 * 0.5;
140    let cy_px = (1.0 - center_ndc.1) * size.1 as f32 * 0.5;
141    let hw = w_px * 0.5;
142    let hh = h_px * 0.5;
143    let (sin_r, cos_r) = rotation.sin_cos();
144
145    let to_ndc = |px: f32, py: f32| -> [f32; 2] {
146        [
147            px / size.0 as f32 * 2.0 - 1.0,
148            1.0 - py / size.1 as f32 * 2.0,
149        ]
150    };
151    let corner = |dx: f32, dy: f32| -> [f32; 2] {
152        let rx = cx_px + dx * cos_r - dy * sin_r;
153        let ry = cy_px + dx * sin_r + dy * cos_r;
154        to_ndc(rx, ry)
155    };
156
157    let tl = SpriteVertex {
158        pos: corner(-hw, -hh),
159        uv: [uv[0], uv[1]],
160    };
161    let tr = SpriteVertex {
162        pos: corner(hw, -hh),
163        uv: [uv[2], uv[1]],
164    };
165    let br = SpriteVertex {
166        pos: corner(hw, hh),
167        uv: [uv[2], uv[3]],
168    };
169    let bl = SpriteVertex {
170        pos: corner(-hw, hh),
171        uv: [uv[0], uv[3]],
172    };
173    // The pipeline is TriangleList, so a quad is two whole triangles. Four
174    // vertices would draw one triangle, drop the other, and bridge into the
175    // next quad in the same batch.
176    [tl, tr, br, tl, br, bl]
177}
178
179const SPRITE_SHADER: &str = r#"
180struct VertexOut {
181    @builtin(position) pos: vec4<f32>,
182    @location(0) uv: vec2<f32>,
183};
184
185@group(0) @binding(0) var sprite_tex: texture_2d<f32>;
186@group(0) @binding(1) var sprite_smp: sampler;
187
188@vertex
189fn vs(@location(0) p: vec2<f32>, @location(1) uv: vec2<f32>) -> VertexOut {
190    var out: VertexOut;
191    out.pos = vec4<f32>(p, 0.0, 1.0);
192    out.uv = uv;
193    return out;
194}
195
196@fragment
197fn fs(in: VertexOut) -> @location(0) vec4<f32> {
198    return textureSample(sprite_tex, sprite_smp, in.uv);
199}
200"#;
201
202struct GpuTexture {
203    #[allow(dead_code)]
204    texture: wgpu::Texture,
205    #[allow(dead_code)]
206    view: wgpu::TextureView,
207    bind_group: wgpu::BindGroup,
208    w: u32,
209    h: u32,
210}
211
212struct QueuedSprite {
213    tex_key: String,
214    world: (f64, f64),
215    size_world: Option<(f64, f64)>,
216    /// Frame size in pixels; `None` means "the whole texture".
217    size_px: Option<(u32, u32)>,
218    uv: [f32; 4],
219    /// Radians, positive = clockwise on screen (y-down NDC).
220    rotation: f32,
221}
222
223impl QueuedSprite {
224    fn plain(tex_key: String, world: (f64, f64)) -> Self {
225        Self {
226            tex_key,
227            world,
228            size_world: None,
229            size_px: None,
230            uv: FULL_UV,
231            rotation: 0.0,
232        }
233    }
234}
235
236fn create_sprite_pipeline(
237    device: &wgpu::Device,
238    format: wgpu::TextureFormat,
239) -> (wgpu::BindGroupLayout, wgpu::RenderPipeline, wgpu::Sampler) {
240    let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
241        label: Some("pite sprite"),
242        source: wgpu::ShaderSource::Wgsl(SPRITE_SHADER.into()),
243    });
244    let tex_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
245        label: Some("pite tex layout"),
246        entries: &[
247            wgpu::BindGroupLayoutEntry {
248                binding: 0,
249                visibility: wgpu::ShaderStages::FRAGMENT,
250                ty: wgpu::BindingType::Texture {
251                    sample_type: wgpu::TextureSampleType::Float { filterable: true },
252                    view_dimension: wgpu::TextureViewDimension::D2,
253                    multisampled: false,
254                },
255                count: None,
256            },
257            wgpu::BindGroupLayoutEntry {
258                binding: 1,
259                visibility: wgpu::ShaderStages::FRAGMENT,
260                ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
261                count: None,
262            },
263        ],
264    });
265    let pipe_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
266        label: Some("pite pipe layout"),
267        bind_group_layouts: &[Some(&tex_layout)],
268        immediate_size: 0,
269    });
270    let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
271        label: Some("pite sprite pipe"),
272        layout: Some(&pipe_layout),
273        vertex: wgpu::VertexState {
274            module: &shader,
275            entry_point: Some("vs"),
276            buffers: &[Some(wgpu::VertexBufferLayout {
277                array_stride: std::mem::size_of::<SpriteVertex>() as u64,
278                step_mode: wgpu::VertexStepMode::Vertex,
279                attributes: &wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x2],
280            })],
281            compilation_options: Default::default(),
282        },
283        primitive: wgpu::PrimitiveState::default(),
284        depth_stencil: None,
285        multisample: wgpu::MultisampleState::default(),
286        fragment: Some(wgpu::FragmentState {
287            module: &shader,
288            entry_point: Some("fs"),
289            targets: &[Some(wgpu::ColorTargetState {
290                format,
291                blend: Some(wgpu::BlendState::ALPHA_BLENDING),
292                write_mask: wgpu::ColorWrites::ALL,
293            })],
294            compilation_options: Default::default(),
295        }),
296        multiview_mask: None,
297        cache: None,
298    });
299    let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
300        label: Some("pite sampler"),
301        mag_filter: wgpu::FilterMode::Nearest,
302        min_filter: wgpu::FilterMode::Nearest,
303        ..Default::default()
304    });
305    (tex_layout, pipeline, sampler)
306}
307
308fn bind_group(
309    device: &wgpu::Device,
310    layout: &wgpu::BindGroupLayout,
311    sampler: &wgpu::Sampler,
312    view: &wgpu::TextureView,
313) -> wgpu::BindGroup {
314    device.create_bind_group(&wgpu::BindGroupDescriptor {
315        label: Some("pite tex bind"),
316        layout,
317        entries: &[
318            wgpu::BindGroupEntry {
319                binding: 0,
320                resource: wgpu::BindingResource::TextureView(view),
321            },
322            wgpu::BindGroupEntry {
323                binding: 1,
324                resource: wgpu::BindingResource::Sampler(sampler),
325            },
326        ],
327    })
328}
329
330fn upload(
331    device: &wgpu::Device,
332    queue: &wgpu::Queue,
333    rgba: &[u8],
334    w: u32,
335    h: u32,
336) -> wgpu::Texture {
337    let size = wgpu::Extent3d {
338        width: w,
339        height: h,
340        depth_or_array_layers: 1,
341    };
342    let tex = device.create_texture(&wgpu::TextureDescriptor {
343        label: Some("pite tex"),
344        size,
345        mip_level_count: 1,
346        sample_count: 1,
347        dimension: wgpu::TextureDimension::D2,
348        format: wgpu::TextureFormat::Rgba8UnormSrgb,
349        usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
350        view_formats: &[],
351    });
352    queue.write_texture(
353        wgpu::TexelCopyTextureInfo {
354            texture: &tex,
355            mip_level: 0,
356            origin: wgpu::Origin3d::ZERO,
357            aspect: wgpu::TextureAspect::All,
358        },
359        rgba,
360        wgpu::TexelCopyBufferLayout {
361            offset: 0,
362            bytes_per_row: Some(4 * w),
363            rows_per_image: Some(h),
364        },
365        size,
366    );
367    tex
368}
369
370/// Shared vertex-build half of `end_frame`: group queued sprites by texture
371/// key, preserving first-seen order. Sizes are resolved by the caller (which
372/// owns the texture cache) so both the surface and offscreen paths share it.
373fn build_groups(
374    sprites: Vec<QueuedSprite>,
375    sizes: &std::collections::HashMap<String, (u32, u32)>,
376    cam: (f64, f64),
377    zoom: f64,
378    size: (u32, u32),
379) -> (
380    std::collections::HashMap<String, Vec<SpriteVertex>>,
381    Vec<String>,
382) {
383    let mut groups: std::collections::HashMap<String, Vec<SpriteVertex>> =
384        std::collections::HashMap::new();
385    let mut order: Vec<String> = Vec::new();
386    for sprite in sprites {
387        let (tw, th) = sizes.get(&sprite.tex_key).copied().unwrap_or((8, 8));
388        // Every size scales with zoom: positions already do, so a sprite
389        // that stayed pixel-fixed while its center moved would shear apart
390        // from the scene on resize (and under Camera2D zoom).
391        let (w, h) = match (sprite.size_world, sprite.size_px) {
392            (Some((w, h)), _) => ((w * zoom) as f32, (h * zoom) as f32),
393            (None, Some((w, h))) => ((w as f64 * zoom) as f32, (h as f64 * zoom) as f32),
394            (None, None) => ((tw as f64 * zoom) as f32, (th as f64 * zoom) as f32),
395        };
396        let screen = world_to_screen(sprite.world, cam, zoom, size);
397        let ndc = screen_to_ndc(screen, size);
398        let quad = quad_for(ndc, w, h, size, sprite.uv, sprite.rotation);
399        if !groups.contains_key(&sprite.tex_key) {
400            order.push(sprite.tex_key.clone());
401        }
402        groups
403            .entry(sprite.tex_key)
404            .or_default()
405            .extend_from_slice(&quad);
406    }
407    (groups, order)
408}
409
410/// Resolve a frame name to UV rect + pixel size, loading the sheet's sidecar
411/// on first use. Missing sidecar or frame is an error, never a silent
412/// full-texture fallback.
413fn frame_uv(
414    cache: &mut std::collections::HashMap<String, Atlas>,
415    texture: &str,
416    frame: &str,
417) -> Result<([f32; 4], (u32, u32))> {
418    if !cache.contains_key(texture) {
419        let sidecar = atlas::sidecar_for(std::path::Path::new(texture));
420        let loaded = atlas::load_atlas(&sidecar)?;
421        cache.insert(texture.to_string(), loaded);
422    }
423    let atlas = &cache[texture];
424    let found = atlas.frame(frame)?;
425    Ok((found.uv(atlas.size), (found.w, found.h)))
426}
427
428/// Encode one render pass drawing pre-built groups into `view`.
429fn encode_groups(
430    device: &wgpu::Device,
431    pipeline: &wgpu::RenderPipeline,
432    textures: &std::collections::HashMap<String, GpuTexture>,
433    groups: &std::collections::HashMap<String, Vec<SpriteVertex>>,
434    order: &[String],
435    view: &wgpu::TextureView,
436) -> wgpu::CommandBuffer {
437    let mut encoder = device.create_command_encoder(&Default::default());
438    {
439        let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
440            label: Some("pite pass"),
441            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
442                view,
443                resolve_target: None,
444                ops: wgpu::Operations {
445                    load: wgpu::LoadOp::Clear(wgpu::Color {
446                        r: 0.07,
447                        g: 0.07,
448                        b: 0.10,
449                        a: 1.0,
450                    }),
451                    store: wgpu::StoreOp::Store,
452                },
453                depth_slice: None,
454            })],
455            depth_stencil_attachment: None,
456            timestamp_writes: None,
457            occlusion_query_set: None,
458            multiview_mask: None,
459        });
460        pass.set_pipeline(pipeline);
461        for key in order {
462            let Some(verts) = groups.get(key) else {
463                continue;
464            };
465            let Some(tex) = textures.get(key) else {
466                continue;
467            };
468            let buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
469                label: Some("pite verts"),
470                contents: bytemuck::cast_slice(verts),
471                usage: wgpu::BufferUsages::VERTEX,
472            });
473            pass.set_bind_group(0, &tex.bind_group, &[]);
474            pass.set_vertex_buffer(0, buf.slice(..));
475            pass.draw(0..verts.len() as u32, 0..1);
476        }
477    }
478    encoder.finish()
479}
480
481/// The GPU objects, caches, sprite queue and camera that the surface and
482/// offscreen paths share. Only submission (and readback) differs between them,
483/// so both renderers hold one of these instead of copying the state.
484struct SharedQueue {
485    device: wgpu::Device,
486    queue: wgpu::Queue,
487    pipeline: wgpu::RenderPipeline,
488    sampler: wgpu::Sampler,
489    tex_layout: wgpu::BindGroupLayout,
490    textures: std::collections::HashMap<String, GpuTexture>,
491    baked: std::collections::HashMap<String, text::BakedText>,
492    baked_order: std::collections::VecDeque<String>,
493    atlas: text::TextAtlas,
494    queue_list: Vec<QueuedSprite>,
495    sprite_atlases: std::collections::HashMap<String, Atlas>,
496    cam: (f64, f64),
497    zoom: f64,
498}
499
500impl SharedQueue {
501    fn new(
502        device: wgpu::Device,
503        queue: wgpu::Queue,
504        pipeline: wgpu::RenderPipeline,
505        sampler: wgpu::Sampler,
506        tex_layout: wgpu::BindGroupLayout,
507    ) -> Result<Self> {
508        Ok(Self {
509            device,
510            queue,
511            pipeline,
512            sampler,
513            tex_layout,
514            textures: std::collections::HashMap::new(),
515            baked: std::collections::HashMap::new(),
516            baked_order: std::collections::VecDeque::new(),
517            atlas: text::TextAtlas::new()?,
518            queue_list: Vec::new(),
519            sprite_atlases: std::collections::HashMap::new(),
520            cam: (0.0, 0.0),
521            zoom: 1.0,
522        })
523    }
524
525    fn set_camera(&mut self, x: f64, y: f64, zoom: f64) {
526        self.cam = (x, y);
527        self.zoom = if zoom > 0.0 { zoom } else { 1.0 };
528    }
529
530    fn load_texture(&self, key: &str) -> GpuTexture {
531        match image::open(key).map(|img| img.to_rgba8()).ok() {
532            Some(rgba) => {
533                let (w, h) = (rgba.width(), rgba.height());
534                let texture = upload(&self.device, &self.queue, &rgba, w, h);
535                let view = texture.create_view(&Default::default());
536                let bind_group = bind_group(&self.device, &self.tex_layout, &self.sampler, &view);
537                GpuTexture {
538                    texture,
539                    view,
540                    bind_group,
541                    w,
542                    h,
543                }
544            }
545            None => {
546                tracing::warn!(texture = key, "cannot load texture, using fallback");
547                let texture = upload(&self.device, &self.queue, &[255, 0, 255, 255], 1, 1);
548                let view = texture.create_view(&Default::default());
549                let bind_group = bind_group(&self.device, &self.tex_layout, &self.sampler, &view);
550                GpuTexture {
551                    texture,
552                    view,
553                    bind_group,
554                    w: 8,
555                    h: 8,
556                }
557            }
558        }
559    }
560
561    fn texture_for(&mut self, key: &str) -> &GpuTexture {
562        if !self.textures.contains_key(key) {
563            let baked = self.baked.get(key).map(|b| (b.rgba.clone(), b.w, b.h));
564            let entry = match baked {
565                Some((rgba, w, h)) => {
566                    let texture = upload(&self.device, &self.queue, &rgba, w, h);
567                    let view = texture.create_view(&Default::default());
568                    let bind_group =
569                        bind_group(&self.device, &self.tex_layout, &self.sampler, &view);
570                    GpuTexture {
571                        texture,
572                        view,
573                        bind_group,
574                        w,
575                        h,
576                    }
577                }
578                None => self.load_texture(key),
579            };
580            self.textures.insert(key.to_string(), entry);
581        }
582        &self.textures[key]
583    }
584
585    fn queue_sprite(&mut self, texture: &str, world: (f64, f64), rotation: f32) {
586        let mut q = QueuedSprite::plain(texture.to_string(), world);
587        q.rotation = rotation;
588        self.queue_list.push(q);
589    }
590
591    fn queue_sprite_frame(
592        &mut self,
593        texture: &str,
594        frame: Option<&str>,
595        world: (f64, f64),
596        rotation: f32,
597    ) -> Result<()> {
598        let Some(frame) = frame else {
599            self.queue_sprite(texture, world, rotation);
600            return Ok(());
601        };
602        let (uv, size_px) = frame_uv(&mut self.sprite_atlases, texture, frame)?;
603        self.queue_list.push(QueuedSprite {
604            tex_key: texture.to_string(),
605            world,
606            size_world: None,
607            size_px: Some(size_px),
608            uv,
609            rotation,
610        });
611        Ok(())
612    }
613
614    fn queue_rect(&mut self, world: (f64, f64), w: f64, h: f64, color: [u8; 4]) {
615        let key = format!(
616            "rect\0{}\0{}\0{}\0{}",
617            color[0], color[1], color[2], color[3]
618        );
619        if !self.baked.contains_key(&key) {
620            self.baked.insert(
621                key.clone(),
622                text::BakedText {
623                    rgba: vec![color[0], color[1], color[2], color[3]],
624                    w: 1,
625                    h: 1,
626                },
627            );
628            text::lru_touch(&mut self.baked_order, &key, 64);
629        }
630        self.queue_list.push(QueuedSprite {
631            size_world: Some((w.max(1.0), h.max(1.0))),
632            ..QueuedSprite::plain(key, world)
633        });
634    }
635
636    fn draw_text_queued(&mut self, text: &str, world: (f64, f64), size: f32, color: [u8; 4]) {
637        if text.is_empty() {
638            return;
639        }
640        let px = ((size as f64 * self.zoom).round().max(1.0)) as u32;
641        let key = text::glyph_cache_key(text, px, color);
642        if !self.baked.contains_key(&key) {
643            self.baked
644                .insert(key.clone(), self.atlas.bake(text, px as f32, color));
645            text::lru_touch(&mut self.baked_order, &key, 64);
646            while self.baked_order.len() > 64 {
647                if let Some(old) = self.baked_order.pop_front() {
648                    self.baked.remove(&old);
649                    self.textures.remove(&old);
650                }
651            }
652        }
653        self.queue_list.push(QueuedSprite::plain(key, world));
654    }
655
656    /// Drain the queue, resolve every queued texture once, and build the
657    /// vertex groups plus the first-seen draw order for a `size`-sized target.
658    fn build(
659        &mut self,
660        size: (u32, u32),
661    ) -> (
662        std::collections::HashMap<String, Vec<SpriteVertex>>,
663        Vec<String>,
664    ) {
665        let sprites = std::mem::take(&mut self.queue_list);
666        for sprite in &sprites {
667            self.texture_for(&sprite.tex_key);
668        }
669        let sizes: std::collections::HashMap<String, (u32, u32)> = self
670            .textures
671            .iter()
672            .map(|(k, t)| (k.clone(), (t.w, t.h)))
673            .collect();
674        build_groups(sprites, &sizes, self.cam, self.zoom, size)
675    }
676
677    fn reload_asset(&mut self, path: &str) {
678        self.textures.remove(path);
679        self.sprite_atlases.remove(path);
680        let path_obj = std::path::Path::new(path);
681        if let Some(stem) = path_obj.file_stem().and_then(|s| s.to_str()) {
682            let pure_stem = stem.strip_suffix(".atlas").unwrap_or(stem);
683            self.textures.retain(|k, _| !k.contains(pure_stem));
684            self.sprite_atlases.retain(|k, _| !k.contains(pure_stem));
685        }
686    }
687
688    fn clear_asset_cache(&mut self) {
689        self.textures.clear();
690        self.sprite_atlases.clear();
691    }
692}
693
694pub struct WgpuRenderer {
695    surface: wgpu::Surface<'static>,
696    config: wgpu::SurfaceConfiguration,
697    shared: SharedQueue,
698}
699
700impl WgpuRenderer {
701    pub fn new(window: std::sync::Arc<winit::window::Window>) -> Result<Self> {
702        let size = window.inner_size();
703        let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
704        let surface = instance.create_surface(window)?;
705        let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
706            power_preference: wgpu::PowerPreference::HighPerformance,
707            compatible_surface: Some(&surface),
708            force_fallback_adapter: false,
709            apply_limit_buckets: false,
710        }))
711        .context("no suitable GPU adapter")?;
712        let (device, queue) =
713            pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default()))
714                .context("cannot request GPU device")?;
715        let caps = surface.get_capabilities(&adapter);
716        let format = caps
717            .formats
718            .iter()
719            .find(|f| f.is_srgb())
720            .copied()
721            .unwrap_or(wgpu::TextureFormat::Bgra8Unorm);
722        let alpha = caps
723            .alpha_modes
724            .first()
725            .copied()
726            .unwrap_or(wgpu::CompositeAlphaMode::Auto);
727        let config = wgpu::SurfaceConfiguration {
728            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
729            format,
730            width: size.width.max(1),
731            height: size.height.max(1),
732            present_mode: wgpu::PresentMode::AutoVsync,
733            alpha_mode: alpha,
734            view_formats: vec![],
735            desired_maximum_frame_latency: 2,
736            color_space: wgpu::SurfaceColorSpace::Auto,
737        };
738        surface.configure(&device, &config);
739
740        let (tex_layout, pipeline, sampler) = create_sprite_pipeline(&device, format);
741        let shared = SharedQueue::new(device, queue, pipeline, sampler, tex_layout)?;
742        Ok(Self {
743            surface,
744            config,
745            shared,
746        })
747    }
748
749    pub fn resize(&mut self, width: u32, height: u32) {
750        if width > 0 && height > 0 {
751            self.config.width = width;
752            self.config.height = height;
753            self.surface.configure(&self.shared.device, &self.config);
754        }
755    }
756
757    pub fn size(&self) -> (u32, u32) {
758        (self.config.width, self.config.height)
759    }
760}
761
762impl Renderer2D for WgpuRenderer {
763    fn begin_frame(&mut self) -> Result<()> {
764        self.shared.queue_list.clear();
765        Ok(())
766    }
767
768    fn draw_sprite(&mut self, texture: &str, x: f64, y: f64, rotation: f64) -> Result<()> {
769        self.shared.queue_sprite(texture, (x, y), rotation as f32);
770        Ok(())
771    }
772
773    fn draw_sprite_frame(
774        &mut self,
775        texture: &str,
776        frame: Option<&str>,
777        x: f64,
778        y: f64,
779        rotation: f64,
780    ) -> Result<()> {
781        self.shared
782            .queue_sprite_frame(texture, frame, (x, y), rotation as f32)
783    }
784
785    fn draw_rect(&mut self, x: f64, y: f64, w: f64, h: f64, color: [u8; 4]) -> Result<()> {
786        self.shared.queue_rect((x, y), w, h, color);
787        Ok(())
788    }
789
790    fn draw_text(&mut self, text: &str, x: f64, y: f64, size: f32, color: [u8; 4]) -> Result<()> {
791        self.shared.draw_text_queued(text, (x, y), size, color);
792        Ok(())
793    }
794
795    fn end_frame(&mut self) -> Result<()> {
796        let size = (self.config.width, self.config.height);
797        let (groups, order) = self.shared.build(size);
798        let frame = match self.surface.get_current_texture() {
799            wgpu::CurrentSurfaceTexture::Success(frame)
800            | wgpu::CurrentSurfaceTexture::Suboptimal(frame) => frame,
801            wgpu::CurrentSurfaceTexture::Outdated | wgpu::CurrentSurfaceTexture::Lost => {
802                self.surface.configure(&self.shared.device, &self.config);
803                return Ok(());
804            }
805            wgpu::CurrentSurfaceTexture::Timeout | wgpu::CurrentSurfaceTexture::Occluded => {
806                return Ok(())
807            }
808            other => anyhow::bail!("surface failed: {other:?}"),
809        };
810        let view = frame.texture.create_view(&Default::default());
811        let cmd = encode_groups(
812            &self.shared.device,
813            &self.shared.pipeline,
814            &self.shared.textures,
815            &groups,
816            &order,
817            &view,
818        );
819        self.shared.queue.submit(std::iter::once(cmd));
820        self.shared.queue.present(frame);
821        Ok(())
822    }
823
824    fn set_camera(&mut self, x: f64, y: f64, zoom: f64) {
825        self.shared.set_camera(x, y, zoom);
826    }
827
828    fn reload_asset(&mut self, path: &str) {
829        self.shared.reload_asset(path);
830    }
831
832    fn clear_asset_cache(&mut self) {
833        self.shared.clear_asset_cache();
834    }
835}
836
837pub struct OffscreenRenderer {
838    target: wgpu::Texture,
839    width: u32,
840    height: u32,
841    shared: SharedQueue,
842}
843
844impl OffscreenRenderer {
845    pub fn new_offscreen(width: u32, height: u32) -> Result<Self> {
846        let width = width.max(1);
847        let height = height.max(1);
848        let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
849        let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
850            power_preference: wgpu::PowerPreference::HighPerformance,
851            compatible_surface: None,
852            force_fallback_adapter: false,
853            apply_limit_buckets: false,
854        }))
855        .context("no suitable GPU adapter")?;
856        let (device, queue) =
857            pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default()))
858                .context("cannot request GPU device")?;
859        let format = wgpu::TextureFormat::Rgba8UnormSrgb;
860        let (tex_layout, pipeline, sampler) = create_sprite_pipeline(&device, format);
861        let target = device.create_texture(&wgpu::TextureDescriptor {
862            label: Some("pite offscreen target"),
863            size: wgpu::Extent3d {
864                width,
865                height,
866                depth_or_array_layers: 1,
867            },
868            mip_level_count: 1,
869            sample_count: 1,
870            dimension: wgpu::TextureDimension::D2,
871            format,
872            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
873            view_formats: &[],
874        });
875        let shared = SharedQueue::new(device, queue, pipeline, sampler, tex_layout)?;
876        Ok(Self {
877            target,
878            width,
879            height,
880            shared,
881        })
882    }
883
884    pub fn size(&self) -> (u32, u32) {
885        (self.width, self.height)
886    }
887
888    fn render_queued_to_target(&mut self) -> wgpu::CommandBuffer {
889        let size = (self.width, self.height);
890        let (groups, order) = self.shared.build(size);
891        let view = self.target.create_view(&Default::default());
892        encode_groups(
893            &self.shared.device,
894            &self.shared.pipeline,
895            &self.shared.textures,
896            &groups,
897            &order,
898            &view,
899        )
900    }
901
902    pub fn render_to_rgba(&mut self) -> Result<Vec<u8>> {
903        let (w, h) = (self.width, self.height);
904        let padded_bytes_per_row: u32 = ((w * 4 + 255) / 256) * 256;
905        let buffer_size: u64 = padded_bytes_per_row as u64 * h as u64;
906        let mut encoder = self
907            .shared
908            .device
909            .create_command_encoder(&Default::default());
910        let size = (w, h);
911        let (groups, order) = self.shared.build(size);
912        let view = self.target.create_view(&Default::default());
913        {
914            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
915                label: Some("pite offscreen pass"),
916                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
917                    view: &view,
918                    resolve_target: None,
919                    ops: wgpu::Operations {
920                        load: wgpu::LoadOp::Clear(wgpu::Color {
921                            r: 0.07,
922                            g: 0.07,
923                            b: 0.10,
924                            a: 1.0,
925                        }),
926                        store: wgpu::StoreOp::Store,
927                    },
928                    depth_slice: None,
929                })],
930                depth_stencil_attachment: None,
931                timestamp_writes: None,
932                occlusion_query_set: None,
933                multiview_mask: None,
934            });
935            pass.set_pipeline(&self.shared.pipeline);
936            for key in &order {
937                let Some(verts) = groups.get(key) else {
938                    continue;
939                };
940                let Some(tex) = self.shared.textures.get(key) else {
941                    continue;
942                };
943                let buf =
944                    self.shared
945                        .device
946                        .create_buffer_init(&wgpu::util::BufferInitDescriptor {
947                            label: Some("pite offscreen verts"),
948                            contents: bytemuck::cast_slice(verts),
949                            usage: wgpu::BufferUsages::VERTEX,
950                        });
951                pass.set_bind_group(0, &tex.bind_group, &[]);
952                pass.set_vertex_buffer(0, buf.slice(..));
953                pass.draw(0..verts.len() as u32, 0..1);
954            }
955        }
956        let readback = self.shared.device.create_buffer(&wgpu::BufferDescriptor {
957            label: Some("pite offscreen readback"),
958            size: buffer_size,
959            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
960            mapped_at_creation: false,
961        });
962        encoder.copy_texture_to_buffer(
963            wgpu::TexelCopyTextureInfo {
964                texture: &self.target,
965                mip_level: 0,
966                origin: wgpu::Origin3d::ZERO,
967                aspect: wgpu::TextureAspect::All,
968            },
969            wgpu::TexelCopyBufferInfo {
970                buffer: &readback,
971                layout: wgpu::TexelCopyBufferLayout {
972                    offset: 0,
973                    bytes_per_row: Some(padded_bytes_per_row),
974                    rows_per_image: Some(h),
975                },
976            },
977            wgpu::Extent3d {
978                width: w,
979                height: h,
980                depth_or_array_layers: 1,
981            },
982        );
983        self.shared.queue.submit(std::iter::once(encoder.finish()));
984        let (tx, rx) = std::sync::mpsc::channel();
985        readback.slice(..).map_async(wgpu::MapMode::Read, move |r| {
986            let _ = tx.send(r);
987        });
988        self.shared
989            .device
990            .poll(wgpu::PollType::wait_indefinitely())
991            .context("offscreen readback poll failed")?;
992        rx.recv()
993            .map_err(|_| anyhow::anyhow!("offscreen readback channel closed"))??;
994        let out = {
995            let view = readback
996                .slice(..)
997                .get_mapped_range()
998                .expect("offscreen readback maps");
999            let row_len = w as usize * 4;
1000            let pitch = padded_bytes_per_row as usize;
1001            let mut out = vec![0u8; row_len * h as usize];
1002            for y in 0..h as usize {
1003                out[y * row_len..(y + 1) * row_len]
1004                    .copy_from_slice(&view[y * pitch..y * pitch + row_len]);
1005            }
1006            out
1007        };
1008        readback.unmap();
1009        Ok(out)
1010    }
1011}
1012
1013impl Renderer2D for OffscreenRenderer {
1014    fn begin_frame(&mut self) -> Result<()> {
1015        self.shared.queue_list.clear();
1016        Ok(())
1017    }
1018
1019    fn draw_sprite(&mut self, texture: &str, x: f64, y: f64, rotation: f64) -> Result<()> {
1020        self.shared.queue_sprite(texture, (x, y), rotation as f32);
1021        Ok(())
1022    }
1023
1024    fn draw_sprite_frame(
1025        &mut self,
1026        texture: &str,
1027        frame: Option<&str>,
1028        x: f64,
1029        y: f64,
1030        rotation: f64,
1031    ) -> Result<()> {
1032        self.shared
1033            .queue_sprite_frame(texture, frame, (x, y), rotation as f32)
1034    }
1035
1036    fn draw_rect(&mut self, x: f64, y: f64, w: f64, h: f64, color: [u8; 4]) -> Result<()> {
1037        self.shared.queue_rect((x, y), w, h, color);
1038        Ok(())
1039    }
1040
1041    fn draw_text(&mut self, text: &str, x: f64, y: f64, size: f32, color: [u8; 4]) -> Result<()> {
1042        self.shared.draw_text_queued(text, (x, y), size, color);
1043        Ok(())
1044    }
1045
1046    fn end_frame(&mut self) -> Result<()> {
1047        let cmd = self.render_queued_to_target();
1048        self.shared.queue.submit(std::iter::once(cmd));
1049        Ok(())
1050    }
1051
1052    fn set_camera(&mut self, x: f64, y: f64, zoom: f64) {
1053        self.shared.set_camera(x, y, zoom);
1054    }
1055
1056    fn reload_asset(&mut self, path: &str) {
1057        self.shared.reload_asset(path);
1058    }
1059
1060    fn clear_asset_cache(&mut self) {
1061        self.shared.clear_asset_cache();
1062    }
1063}
1064
1065#[cfg(test)]
1066mod tests {
1067    use super::*;
1068
1069    #[test]
1070    fn camera_frames_world_into_ndc() {
1071        let size = (800, 600);
1072        let (sx, sy) = world_to_screen((100.0, 200.0), (100.0, 200.0), 1.0, size);
1073        assert_eq!((sx, sy), (400.0, 300.0));
1074        let (nx, ny) = screen_to_ndc((sx, sy), size);
1075        assert!((nx.abs() < 1e-6) && (ny.abs() < 1e-6));
1076        let (sx2, _) = world_to_screen((110.0, 200.0), (100.0, 200.0), 2.0, size);
1077        assert!((sx2 - 420.0).abs() < 1e-6);
1078    }
1079
1080    #[test]
1081    fn quad_is_centered_and_textured() {
1082        let quad = quad_for((0.0, 0.0), 32.0, 32.0, (800, 600), FULL_UV, 0.0);
1083        assert_eq!(quad.len(), 6);
1084        let to_px = |ndc: f32, size: u32| (ndc + 1.0) / 2.0 * size as f32;
1085        assert!((to_px(quad[0].pos[0], 800) - 384.0).abs() < 0.01);
1086        assert!((to_px(quad[1].pos[0], 800) - 416.0).abs() < 0.01);
1087        assert!((to_px(quad[0].pos[1], 600) - 316.0).abs() < 0.01);
1088        assert!((to_px(quad[2].pos[1], 600) - 284.0).abs() < 0.01);
1089        assert_eq!(quad[0].uv, [0.0, 0.0]);
1090        assert_eq!(quad[2].uv, [1.0, 1.0]);
1091    }
1092
1093    #[test]
1094    fn rotated_quad_has_rotated_corners() {
1095        // 100x50 sprite centered on a 200x200 canvas. 90° moves the
1096        // top-left corner from pixel (-50, -25) to (25, -50), i.e. NDC
1097        // (-0.5, 0.25) → (0.25, 0.5).
1098        let quad = quad_for(
1099            (0.0, 0.0),
1100            100.0,
1101            50.0,
1102            (200, 200),
1103            FULL_UV,
1104            std::f32::consts::FRAC_PI_2,
1105        );
1106        assert!((quad[0].pos[0] - 0.25).abs() < 0.01, "{}", quad[0].pos[0]);
1107        assert!((quad[0].pos[1] - 0.5).abs() < 0.01, "{}", quad[0].pos[1]);
1108    }
1109
1110    #[test]
1111    fn screen_to_world_inverts_projection() {
1112        let size = (800, 600);
1113        let world = (150.0, -40.0);
1114        let screen = world_to_screen(world, (100.0, 200.0), 2.0, size);
1115        let back = screen_to_world(screen, (100.0, 200.0), 2.0, size);
1116        assert!((back.0 - world.0).abs() < 1e-3 && (back.1 - world.1).abs() < 1e-3);
1117        let center = screen_to_world((400.0, 300.0), (100.0, 200.0), 1.0, size);
1118        assert!((center.0 - 100.0).abs() < 1e-9 && (center.1 - 200.0).abs() < 1e-9);
1119    }
1120
1121    #[test]
1122    fn fit_scale_contains_and_guards_zeros() {
1123        assert!((fit_scale((1280, 720), (1920, 1080)) - 1.5).abs() < 1e-9);
1124        assert!((fit_scale((1280, 720), (1920, 800)) - (800.0 / 720.0)).abs() < 1e-9);
1125        assert!((fit_scale((1280, 720), (640, 360)) - 0.5).abs() < 1e-9);
1126        assert_eq!(fit_scale((0, 720), (1920, 1080)), 1.0);
1127        assert_eq!(fit_scale((1280, 720), (0, 0)), 1.0);
1128    }
1129
1130    #[test]
1131    fn demo_textures_decode() {
1132        let sheet = image::open("../../examples/minimal-2d/assets/sheet.png").unwrap();
1133        assert_eq!((sheet.width(), sheet.height()), (128, 64));
1134    }
1135
1136    #[test]
1137    fn sprite_renders_green_pixel_offscreen() {
1138        let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
1139        let Ok(adapter) =
1140            pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
1141                power_preference: wgpu::PowerPreference::LowPower,
1142                compatible_surface: None,
1143                force_fallback_adapter: true,
1144                apply_limit_buckets: false,
1145            }))
1146        else {
1147            eprintln!("SKIP: no fallback GPU adapter on this machine");
1148            return;
1149        };
1150        let (device, queue) =
1151            pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())).unwrap();
1152        let format = wgpu::TextureFormat::Rgba8Unorm;
1153        let (tex_layout, pipeline, sampler) = create_sprite_pipeline(&device, format);
1154
1155        let sheet = image::open("../../examples/minimal-2d/assets/sheet.png")
1156            .unwrap()
1157            .to_rgba8();
1158        // The `player` frame's rect from sheet.atlas.json: (0, 0, 32, 32).
1159        let rgba = image::imageops::crop_imm(&sheet, 0, 0, 32, 32).to_image();
1160        let sprite = upload(&device, &queue, &rgba, 32, 32);
1161        let sprite_view = sprite.create_view(&Default::default());
1162        let sprite_bg = bind_group(&device, &tex_layout, &sampler, &sprite_view);
1163
1164        let target = device.create_texture(&wgpu::TextureDescriptor {
1165            label: Some("pite test target"),
1166            size: wgpu::Extent3d {
1167                width: 64,
1168                height: 64,
1169                depth_or_array_layers: 1,
1170            },
1171            mip_level_count: 1,
1172            sample_count: 1,
1173            dimension: wgpu::TextureDimension::D2,
1174            format,
1175            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
1176            view_formats: &[],
1177        });
1178        let target_view = target.create_view(&Default::default());
1179        let quad = [
1180            SpriteVertex {
1181                pos: [-1.0, 1.0],
1182                uv: [0.0, 0.0],
1183            },
1184            SpriteVertex {
1185                pos: [1.0, 1.0],
1186                uv: [1.0, 0.0],
1187            },
1188            SpriteVertex {
1189                pos: [1.0, -1.0],
1190                uv: [1.0, 1.0],
1191            },
1192            SpriteVertex {
1193                pos: [-1.0, 1.0],
1194                uv: [0.0, 0.0],
1195            },
1196            SpriteVertex {
1197                pos: [1.0, -1.0],
1198                uv: [1.0, 1.0],
1199            },
1200            SpriteVertex {
1201                pos: [-1.0, -1.0],
1202                uv: [0.0, 1.0],
1203            },
1204        ];
1205        let vbuf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
1206            label: Some("pite test verts"),
1207            contents: bytemuck::cast_slice(&quad),
1208            usage: wgpu::BufferUsages::VERTEX,
1209        });
1210        let mut encoder = device.create_command_encoder(&Default::default());
1211        {
1212            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1213                label: Some("pite test pass"),
1214                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1215                    view: &target_view,
1216                    resolve_target: None,
1217                    ops: wgpu::Operations {
1218                        load: wgpu::LoadOp::Clear(wgpu::Color {
1219                            r: 0.0,
1220                            g: 0.0,
1221                            b: 0.0,
1222                            a: 1.0,
1223                        }),
1224                        store: wgpu::StoreOp::Store,
1225                    },
1226                    depth_slice: None,
1227                })],
1228                depth_stencil_attachment: None,
1229                timestamp_writes: None,
1230                occlusion_query_set: None,
1231                multiview_mask: None,
1232            });
1233            pass.set_pipeline(&pipeline);
1234            pass.set_bind_group(0, &sprite_bg, &[]);
1235            pass.set_vertex_buffer(0, vbuf.slice(..));
1236            pass.draw(0..4, 0..1);
1237        }
1238        let readback = device.create_buffer(&wgpu::BufferDescriptor {
1239            label: Some("pite test readback"),
1240            size: 64 * 64 * 4,
1241            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
1242            mapped_at_creation: false,
1243        });
1244        encoder.copy_texture_to_buffer(
1245            wgpu::TexelCopyTextureInfo {
1246                texture: &target,
1247                mip_level: 0,
1248                origin: wgpu::Origin3d::ZERO,
1249                aspect: wgpu::TextureAspect::All,
1250            },
1251            wgpu::TexelCopyBufferInfo {
1252                buffer: &readback,
1253                layout: wgpu::TexelCopyBufferLayout {
1254                    offset: 0,
1255                    bytes_per_row: Some(64 * 4),
1256                    rows_per_image: Some(64),
1257                },
1258            },
1259            wgpu::Extent3d {
1260                width: 64,
1261                height: 64,
1262                depth_or_array_layers: 1,
1263            },
1264        );
1265        queue.submit(std::iter::once(encoder.finish()));
1266        let (tx, rx) = std::sync::mpsc::channel();
1267        readback.slice(..).map_async(wgpu::MapMode::Read, move |r| {
1268            tx.send(r).unwrap();
1269        });
1270        device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
1271        rx.recv().unwrap().unwrap();
1272        let center: [u8; 4];
1273        {
1274            let view = readback
1275                .slice(..)
1276                .get_mapped_range()
1277                .expect("offscreen readback maps");
1278            let i = (32 * 64 + 32) * 4;
1279            center = [view[i], view[i + 1], view[i + 2], view[i + 3]];
1280        }
1281        readback.unmap();
1282        assert!(
1283            center[1] as i32 - center[0] as i32 > 60,
1284            "center pixel should be sprite-green, got {center:?}"
1285        );
1286    }
1287
1288    #[test]
1289    fn offscreen_clear_is_srgb_encoded_to_match_the_window_surface() {
1290        let Ok(mut r) = OffscreenRenderer::new_offscreen(8, 8) else {
1291            eprintln!("SKIP: no GPU adapter on this machine");
1292            return;
1293        };
1294        let rgba = r.render_to_rgba().expect("offscreen readback");
1295        assert_eq!(
1296            &rgba[0..3],
1297            &[75, 75, 89],
1298            "editor viewport must show the same background as the game window"
1299        );
1300    }
1301
1302    #[test]
1303    fn offscreen_sprite_keeps_authored_srgb_colors() {
1304        let Ok(mut r) = OffscreenRenderer::new_offscreen(64, 64) else {
1305            eprintln!("SKIP: no GPU adapter on this machine");
1306            return;
1307        };
1308        r.draw_sprite_frame(
1309            "../../examples/minimal-2d/assets/sheet.png",
1310            Some("player"),
1311            0.0,
1312            0.0,
1313            0.0,
1314        )
1315        .expect("queue sprite");
1316        let rgba = r.render_to_rgba().expect("offscreen readback");
1317        let seen: std::collections::HashSet<[u8; 4]> = rgba
1318            .chunks_exact(4)
1319            .map(|c| [c[0], c[1], c[2], c[3]])
1320            .collect();
1321        assert!(
1322            seen.contains(&[74, 222, 128, 255]),
1323            "authored green must survive the round-trip, saw {:?}",
1324            seen
1325        );
1326    }
1327
1328    #[test]
1329    fn offscreen_sprite_covers_its_whole_texture_rect() {
1330        let Ok(mut r) = OffscreenRenderer::new_offscreen(200, 200) else {
1331            eprintln!("SKIP: no GPU adapter on this machine");
1332            return;
1333        };
1334        r.draw_sprite_frame(
1335            "../../examples/minimal-2d/assets/sheet.png",
1336            Some("player"),
1337            0.0,
1338            0.0,
1339            0.0,
1340        )
1341        .expect("queue sprite");
1342        let rgba = r.render_to_rgba().expect("offscreen readback");
1343        let clear = (75u8, 75u8, 89u8);
1344        let mut min = (200usize, 200usize);
1345        let mut max = (0usize, 0usize);
1346        for y in 0..200usize {
1347            for x in 0..200usize {
1348                let i = (y * 200 + x) * 4;
1349                if (rgba[i], rgba[i + 1], rgba[i + 2]) != clear {
1350                    min.0 = min.0.min(x);
1351                    min.1 = min.1.min(y);
1352                    max.0 = max.0.max(x);
1353                    max.1 = max.1.max(y);
1354                }
1355            }
1356        }
1357        assert_eq!(
1358            (min.0, min.1, max.0 - min.0 + 1, max.1 - min.1 + 1),
1359            (68, 68, 64, 64),
1360            "a 64x64 frame must fill 64x64 pixels, centered in a 200x200 target"
1361        );
1362    }
1363
1364    #[test]
1365    fn baked_text_renders_bright_pixels_offscreen() {
1366        let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
1367        let Ok(adapter) =
1368            pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
1369                power_preference: wgpu::PowerPreference::LowPower,
1370                compatible_surface: None,
1371                force_fallback_adapter: true,
1372                apply_limit_buckets: false,
1373            }))
1374        else {
1375            eprintln!("SKIP: no fallback GPU adapter on this machine");
1376            return;
1377        };
1378        let (device, queue) =
1379            pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())).unwrap();
1380        let format = wgpu::TextureFormat::Rgba8Unorm;
1381        let (tex_layout, pipeline, sampler) = create_sprite_pipeline(&device, format);
1382
1383        let atlas = text::TextAtlas::new().unwrap();
1384        let baked = atlas.bake("HP", 32.0, [255, 255, 255, 255]);
1385        let glyph = upload(&device, &queue, &baked.rgba, baked.w, baked.h);
1386        let glyph_view = glyph.create_view(&Default::default());
1387        let glyph_bg = bind_group(&device, &tex_layout, &sampler, &glyph_view);
1388
1389        let target = device.create_texture(&wgpu::TextureDescriptor {
1390            label: Some("pite text test target"),
1391            size: wgpu::Extent3d {
1392                width: 64,
1393                height: 64,
1394                depth_or_array_layers: 1,
1395            },
1396            mip_level_count: 1,
1397            sample_count: 1,
1398            dimension: wgpu::TextureDimension::D2,
1399            format,
1400            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
1401            view_formats: &[],
1402        });
1403        let target_view = target.create_view(&Default::default());
1404        let quad = [
1405            SpriteVertex {
1406                pos: [-1.0, 1.0],
1407                uv: [0.0, 0.0],
1408            },
1409            SpriteVertex {
1410                pos: [1.0, 1.0],
1411                uv: [1.0, 0.0],
1412            },
1413            SpriteVertex {
1414                pos: [1.0, -1.0],
1415                uv: [1.0, 1.0],
1416            },
1417            SpriteVertex {
1418                pos: [-1.0, 1.0],
1419                uv: [0.0, 0.0],
1420            },
1421            SpriteVertex {
1422                pos: [1.0, -1.0],
1423                uv: [1.0, 1.0],
1424            },
1425            SpriteVertex {
1426                pos: [-1.0, -1.0],
1427                uv: [0.0, 1.0],
1428            },
1429        ];
1430        let vbuf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
1431            label: Some("pite text test verts"),
1432            contents: bytemuck::cast_slice(&quad),
1433            usage: wgpu::BufferUsages::VERTEX,
1434        });
1435        let mut encoder = device.create_command_encoder(&Default::default());
1436        {
1437            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1438                label: Some("pite text test pass"),
1439                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1440                    view: &target_view,
1441                    resolve_target: None,
1442                    ops: wgpu::Operations {
1443                        load: wgpu::LoadOp::Clear(wgpu::Color {
1444                            r: 0.0,
1445                            g: 0.0,
1446                            b: 0.2,
1447                            a: 1.0,
1448                        }),
1449                        store: wgpu::StoreOp::Store,
1450                    },
1451                    depth_slice: None,
1452                })],
1453                depth_stencil_attachment: None,
1454                timestamp_writes: None,
1455                occlusion_query_set: None,
1456                multiview_mask: None,
1457            });
1458            pass.set_pipeline(&pipeline);
1459            pass.set_bind_group(0, &glyph_bg, &[]);
1460            pass.set_vertex_buffer(0, vbuf.slice(..));
1461            pass.draw(0..4, 0..1);
1462        }
1463        let readback = device.create_buffer(&wgpu::BufferDescriptor {
1464            label: Some("pite text test readback"),
1465            size: 64 * 64 * 4,
1466            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
1467            mapped_at_creation: false,
1468        });
1469        encoder.copy_texture_to_buffer(
1470            wgpu::TexelCopyTextureInfo {
1471                texture: &target,
1472                mip_level: 0,
1473                origin: wgpu::Origin3d::ZERO,
1474                aspect: wgpu::TextureAspect::All,
1475            },
1476            wgpu::TexelCopyBufferInfo {
1477                buffer: &readback,
1478                layout: wgpu::TexelCopyBufferLayout {
1479                    offset: 0,
1480                    bytes_per_row: Some(64 * 4),
1481                    rows_per_image: Some(64),
1482                },
1483            },
1484            wgpu::Extent3d {
1485                width: 64,
1486                height: 64,
1487                depth_or_array_layers: 1,
1488            },
1489        );
1490        queue.submit(std::iter::once(encoder.finish()));
1491        let (tx, rx) = std::sync::mpsc::channel();
1492        readback.slice(..).map_async(wgpu::MapMode::Read, move |r| {
1493            tx.send(r).unwrap();
1494        });
1495        device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
1496        rx.recv().unwrap().unwrap();
1497        let bright = {
1498            let view = readback
1499                .slice(..)
1500                .get_mapped_range()
1501                .expect("offscreen readback maps");
1502            view.chunks(4)
1503                .filter(|px| px[0] > 200 && px[1] > 200 && px[2] > 200)
1504                .count()
1505        };
1506        readback.unmap();
1507        assert!(bright > 10, "expected bright glyph pixels, got {bright}");
1508    }
1509}
1510
1511#[cfg(test)]
1512mod atlas_draw_tests {
1513    use super::*;
1514    use std::collections::HashMap;
1515
1516    fn sheet_frame(name: &str) -> (Atlas, AtlasFrame) {
1517        let atlas = atlas::parse_atlas(
1518            r#"{"texture":"sheet.png","size":[64,32],
1519                "frames":{"player":{"x":0,"y":0,"w":32,"h":32},
1520                          "enemy":{"x":32,"y":0,"w":32,"h":32}}}"#,
1521        )
1522        .unwrap();
1523        let frame = atlas.frame(name).unwrap();
1524        (atlas, frame)
1525    }
1526
1527    fn queued(atlas: &Atlas, frame: AtlasFrame, world: (f64, f64)) -> QueuedSprite {
1528        QueuedSprite {
1529            tex_key: "sheet.png".to_string(),
1530            world,
1531            size_world: None,
1532            size_px: Some((frame.w, frame.h)),
1533            uv: frame.uv(atlas.size),
1534            rotation: 0.0,
1535        }
1536    }
1537
1538    #[test]
1539    fn atlas_frames_from_one_sheet_batch_into_one_group() {
1540        let (atlas, player) = sheet_frame("player");
1541        let (_, enemy) = sheet_frame("enemy");
1542        let sprites = vec![
1543            queued(&atlas, player, (0.0, 0.0)),
1544            queued(&atlas, enemy, (40.0, 0.0)),
1545        ];
1546        let sizes: HashMap<String, (u32, u32)> =
1547            HashMap::from([("sheet.png".to_string(), atlas.size)]);
1548        let (groups, order) = build_groups(sprites, &sizes, (0.0, 0.0), 1.0, (200, 100));
1549
1550        assert_eq!(order, vec!["sheet.png".to_string()], "one sheet, one draw");
1551        let verts = groups.get("sheet.png").expect("group exists");
1552        assert_eq!(verts.len(), 12, "two quads share the group");
1553
1554        let player_uvs: Vec<[f32; 2]> = verts[..6].iter().map(|v| v.uv).collect();
1555        assert_eq!(player_uvs[0], [0.0, 0.0]);
1556        assert_eq!(player_uvs[2], [0.5, 1.0]);
1557        let enemy_uvs: Vec<[f32; 2]> = verts[6..].iter().map(|v| v.uv).collect();
1558        assert_eq!(enemy_uvs[0], [0.5, 0.0]);
1559        assert_eq!(enemy_uvs[2], [1.0, 1.0]);
1560    }
1561
1562    #[test]
1563    fn frame_quad_uses_frame_size_not_sheet_size() {
1564        let (atlas, player) = sheet_frame("player");
1565        let sprites = vec![queued(&atlas, player, (0.0, 0.0))];
1566        let sizes: HashMap<String, (u32, u32)> =
1567            HashMap::from([("sheet.png".to_string(), atlas.size)]);
1568        let (groups, _) = build_groups(sprites, &sizes, (0.0, 0.0), 1.0, (200, 100));
1569        let verts = &groups["sheet.png"];
1570        let width_ndc = verts[1].pos[0] - verts[0].pos[0];
1571        // 32px frame in a 200px-wide view, doubled because NDC spans both edges.
1572        assert!(
1573            (width_ndc - 2.0 * 32.0 / 200.0).abs() < 1e-6,
1574            "got {width_ndc}"
1575        );
1576    }
1577
1578    #[test]
1579    fn missing_sidecar_and_frame_are_loud() {
1580        let mut cache: HashMap<String, Atlas> = HashMap::new();
1581        let err = frame_uv(&mut cache, "/nowhere/sheet.png", "player").unwrap_err();
1582        assert!(
1583            format!("{err:#}").contains("cannot read atlas"),
1584            "got {err:#}"
1585        );
1586    }
1587}