1use 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 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
103pub 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 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 [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 size_px: Option<(u32, u32)>,
218 uv: [f32; 4],
219 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
370fn 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 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
410fn 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
428fn 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
481struct 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 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 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 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 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}