Skip to main content

pite_render/
lib.rs

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