1use 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 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
98pub 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 [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 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
343fn 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 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
383fn 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
401fn 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
454struct 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 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 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 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}