1use std::collections::HashMap;
26
27use bytemuck::{Pod, Zeroable};
28use glam::Mat4;
29use repose_render_wgpu::{CallbackResources, ScreenDescriptor, WgpuCallback};
30use wgpu::util::DeviceExt;
31
32use super::{SpriteBlend, SpriteInstance};
33
34#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
36pub enum TextureFilter {
37 #[default]
39 Nearest,
40 Linear,
41}
42
43#[derive(Clone, Copy, Debug)]
45pub struct BatchDesc {
46 pub layer_size: u32,
48 pub layers: u32,
50 pub filter: TextureFilter,
51 pub uploads_gen: u64,
53}
54
55impl Default for BatchDesc {
56 fn default() -> Self {
57 Self {
58 layer_size: 2048,
59 layers: 4,
60 filter: TextureFilter::Nearest,
61 uploads_gen: 0,
62 }
63 }
64}
65
66#[derive(Clone, Debug)]
69pub struct AtlasUpload {
70 pub page: u32,
71 pub x: u32,
72 pub y: u32,
73 pub w: u32,
74 pub h: u32,
75 pub rgba: Vec<u8>,
76}
77
78impl AtlasUpload {
79 pub fn from_write(write: &repame_atlas::AtlasWrite, rgba: Vec<u8>) -> Self {
83 Self {
84 page: write.page,
85 x: write.x,
86 y: write.y,
87 w: write.w,
88 h: write.h,
89 rgba,
90 }
91 }
92
93 pub fn white(page: u32, x: u32, y: u32) -> Self {
98 Self {
99 page,
100 x,
101 y,
102 w: 1,
103 h: 1,
104 rgba: vec![255, 255, 255, 255],
105 }
106 }
107
108 pub fn white_for(write: &repame_atlas::AtlasWrite) -> Self {
111 Self {
112 page: write.page,
113 x: write.x,
114 y: write.y,
115 w: write.w.max(1),
116 h: write.h.max(1),
117 rgba: vec![255; write.w.max(1) as usize * write.h.max(1) as usize * 4],
118 }
119 }
120}
121
122pub fn instance_rows(
127 center: [f32; 2],
128 size: [f32; 2],
129 rotation: f32,
130 anchor: [f32; 2],
131 flip_x: bool,
132 flip_y: bool,
133) -> ([f32; 4], [f32; 4]) {
134 let (c, s) = (rotation.cos(), rotation.sin());
135 let sx = if flip_x { -size[0] } else { size[0] };
136 let sy = if flip_y { -size[1] } else { size[1] };
137 let (ax, ay) = (0.5 - anchor[0], 0.5 - anchor[1]);
140 (
141 [c * sx, -s * sy, 0.0, center[0] + c * sx * ax - s * sy * ay],
142 [s * sx, c * sy, 0.0, center[1] + s * sx * ax + c * sy * ay],
143 )
144}
145
146pub fn frame_uv(hframes: u32, vframes: u32, frame: u32) -> ([f32; 2], [f32; 2]) {
168 let hf = hframes.max(1);
169 let vf = vframes.max(1);
170 let f = frame.min(hf * vf - 1);
171 let col = f % hf;
172 let row = f / hf;
173 (
174 [col as f32 / hf as f32, row as f32 / vf as f32],
175 [(col + 1) as f32 / hf as f32, (row + 1) as f32 / vf as f32],
176 )
177}
178
179pub fn sprite_aabb(
197 center: [f32; 2],
198 size: [f32; 2],
199 rotation: f32,
200 anchor: [f32; 2],
201 flip_x: bool,
202 flip_y: bool,
203) -> ([f32; 2], [f32; 2]) {
204 let (row0, row1) = instance_rows(center, size, rotation, anchor, flip_x, flip_y);
205 let mut min = [f32::INFINITY, f32::INFINITY];
206 let mut max = [f32::NEG_INFINITY, f32::NEG_INFINITY];
207 for corner in [[-0.5, -0.5], [0.5, -0.5], [0.5, 0.5], [-0.5, 0.5]] {
208 let x = row0[0] * corner[0] + row0[1] * corner[1] + row0[3];
209 let y = row1[0] * corner[0] + row1[1] * corner[1] + row1[3];
210 min[0] = min[0].min(x);
211 min[1] = min[1].min(y);
212 max[0] = max[0].max(x);
213 max[1] = max[1].max(y);
214 }
215 (min, max)
216}
217
218pub fn screen_camera(viewport_px: [f32; 2]) -> Mat4 {
223 glam::camera::rh::proj::directx::orthographic(
225 0.0,
226 viewport_px[0].max(1.0),
227 viewport_px[1].max(1.0),
228 0.0,
229 -1000.0,
230 1000.0,
231 )
232}
233
234#[repr(C)]
235#[derive(Clone, Copy, Pod, Zeroable)]
236struct CameraUniform {
237 view_proj: [[f32; 4]; 4],
238}
239
240#[repr(C)]
241#[derive(Clone, Copy, Pod, Zeroable)]
242struct BatchInstance {
243 row0: [f32; 4],
244 row1: [f32; 4],
245 uv_min: [f32; 2],
246 uv_max: [f32; 2],
247 tint: [f32; 4],
248 page: f32,
249 z: f32,
250 flags: u32,
251 _pad: u32,
252}
253
254const _: () = assert!(size_of::<BatchInstance>() == 80);
259
260pub struct SpriteBatch {
268 id: String,
269 desc: BatchDesc,
270 camera: [[f32; 4]; 4],
271 instances: Vec<BatchInstance>,
272 uploads: Vec<AtlasUpload>,
273}
274
275impl SpriteBatch {
276 pub fn new(desc: BatchDesc) -> Self {
277 Self::with_id("sprite_batch.default", desc)
278 }
279
280 pub fn with_id(id: impl Into<String>, desc: BatchDesc) -> Self {
281 Self {
282 id: id.into(),
283 desc,
284 camera: Mat4::IDENTITY.to_cols_array_2d(),
285 instances: Vec::new(),
286 uploads: Vec::new(),
287 }
288 }
289
290 pub fn id(&self) -> &str {
291 &self.id
292 }
293
294 pub fn clear(&mut self) {
295 self.instances.clear();
296 self.uploads.clear();
297 }
298
299 pub fn len(&self) -> usize {
300 self.instances.len()
301 }
302
303 pub fn is_empty(&self) -> bool {
304 self.instances.is_empty()
305 }
306
307 pub fn set_camera(&mut self, view_proj: Mat4) {
310 self.camera = view_proj.to_cols_array_2d();
311 }
312
313 pub fn upload(&mut self, upload: AtlasUpload) {
315 self.uploads.push(upload);
316 }
317
318 pub fn extend_uploads(&mut self, uploads: impl IntoIterator<Item = AtlasUpload>) {
320 self.uploads.extend(uploads);
321 }
322
323 #[allow(clippy::too_many_arguments)]
325 pub fn push(
326 &mut self,
327 center: [f32; 2],
328 size: [f32; 2],
329 rotation: f32,
330 anchor: [f32; 2],
331 flip_x: bool,
332 flip_y: bool,
333 uv_min: [f32; 2],
334 uv_max: [f32; 2],
335 tint: [f32; 4],
336 page: u32,
337 ) {
338 self.push_blended(
339 center,
340 size,
341 rotation,
342 anchor,
343 flip_x,
344 flip_y,
345 uv_min,
346 uv_max,
347 tint,
348 page,
349 0.0,
350 SpriteBlend::Alpha,
351 );
352 }
353
354 #[allow(clippy::too_many_arguments)]
356 pub fn push_blended(
357 &mut self,
358 center: [f32; 2],
359 size: [f32; 2],
360 rotation: f32,
361 anchor: [f32; 2],
362 flip_x: bool,
363 flip_y: bool,
364 uv_min: [f32; 2],
365 uv_max: [f32; 2],
366 tint: [f32; 4],
367 page: u32,
368 z: f32,
369 blend: SpriteBlend,
370 ) {
371 let (row0, row1) = instance_rows(center, size, rotation, anchor, flip_x, flip_y);
372 let flags = match blend {
373 SpriteBlend::Alpha => 0,
374 SpriteBlend::Additive => 1,
375 SpriteBlend::Multiply => 2,
376 };
377 self.instances.push(BatchInstance {
378 row0,
379 row1,
380 uv_min,
381 uv_max,
382 tint,
383 page: page as f32,
384 z,
385 flags,
386 _pad: 0,
387 });
388 }
389
390 pub fn push_sprite(&mut self, s: &SpriteInstance) {
392 self.push_blended(
393 [s.center.x, s.center.y],
394 [s.size.x, s.size.y],
395 s.rotation,
396 [s.anchor.x, s.anchor.y],
397 s.flip_x,
398 s.flip_y,
399 [s.uv_min.x, s.uv_min.y],
400 [s.uv_max.x, s.uv_max.y],
401 s.color,
402 s.page,
403 s.z,
404 s.blend,
405 );
406 }
407}
408
409struct BatchEntry {
410 key: (wgpu::TextureFormat, u32, u32, u32, TextureFilter, u64),
411 pipeline_alpha: wgpu::RenderPipeline,
412 pipeline_multiply: wgpu::RenderPipeline,
413 pipeline_additive: wgpu::RenderPipeline,
414 corners: wgpu::Buffer,
415 instances: wgpu::Buffer,
416 instance_cap: usize,
417 last_alpha: u32,
420 last_multiply_end: u32,
421 last_total: u32,
422 applied_gen: u64,
426 camera: wgpu::Buffer,
427 cam_bind: wgpu::BindGroup,
428 tex_bind: wgpu::BindGroup,
429 texture: wgpu::Texture,
430}
431
432struct BatchResources {
433 batches: HashMap<String, BatchEntry>,
434}
435
436const CORNERS: &[f32] = &[
437 -0.5, -0.5, 0.5, -0.5, 0.5, 0.5, -0.5, -0.5, 0.5, 0.5, -0.5, 0.5, ];
444
445fn sampler_desc(filter: TextureFilter) -> wgpu::SamplerDescriptor<'static> {
446 let f = match filter {
447 TextureFilter::Nearest => wgpu::FilterMode::Nearest,
448 TextureFilter::Linear => wgpu::FilterMode::Linear,
449 };
450 let m = match filter {
451 TextureFilter::Nearest => wgpu::MipmapFilterMode::Nearest,
452 TextureFilter::Linear => wgpu::MipmapFilterMode::Linear,
453 };
454 wgpu::SamplerDescriptor {
455 label: Some("sprite_batch_sampler"),
456 address_mode_u: wgpu::AddressMode::ClampToEdge,
457 address_mode_v: wgpu::AddressMode::ClampToEdge,
458 address_mode_w: wgpu::AddressMode::ClampToEdge,
459 mag_filter: f,
460 min_filter: f,
461 mipmap_filter: m,
462 lod_min_clamp: 0.0,
463 lod_max_clamp: 1.0,
464 compare: None,
465 anisotropy_clamp: 1,
466 border_color: None,
467 }
468}
469
470impl SpriteBatch {
471 fn ensure_resources(
472 &self,
473 device: &wgpu::Device,
474 screen: &ScreenDescriptor,
475 resources: &mut CallbackResources,
476 ) {
477 let key = (
478 screen.target_format,
479 screen.sample_count,
480 self.desc.layer_size,
481 self.desc.layers,
482 self.desc.filter,
483 self.desc.uploads_gen,
484 );
485 let needs = match resources.get::<BatchResources>() {
486 None => true,
487 Some(all) => match all.batches.get(self.id.as_str()) {
488 None => true,
489 Some(e) => e.key != key,
490 },
491 };
492 if !needs {
493 return;
494 }
495 if resources
496 .get::<BatchResources>()
497 .is_some_and(|all| all.batches.contains_key(self.id.as_str()))
498 {
499 log::warn!(
500 "sprite_batch[{}]: rebuilding pipeline/texture (format/sample/desc changed); atlas contents dropped, re-upload required",
501 self.id
502 );
503 }
504 let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
505 label: Some("sprite_batch"),
506 source: wgpu::ShaderSource::Wgsl(include_str!("../shaders/sprite.wgsl").into()),
507 });
508 let corners = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
509 label: Some("sprite_batch_corners"),
510 contents: bytemuck::cast_slice(CORNERS),
511 usage: wgpu::BufferUsages::VERTEX,
512 });
513 let camera = device.create_buffer(&wgpu::BufferDescriptor {
514 label: Some("sprite_batch_camera"),
515 size: 64,
516 usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
517 mapped_at_creation: false,
518 });
519 let instances = device.create_buffer(&wgpu::BufferDescriptor {
520 label: Some("sprite_batch_instances"),
521 size: (self.instances.len().max(1) * size_of::<BatchInstance>()) as u64,
522 usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
523 mapped_at_creation: false,
524 });
525 let texture = device.create_texture(&wgpu::TextureDescriptor {
526 label: Some("sprite_batch_atlas"),
527 size: wgpu::Extent3d {
528 width: self.desc.layer_size,
529 height: self.desc.layer_size,
530 depth_or_array_layers: self.desc.layers,
531 },
532 mip_level_count: 1,
533 sample_count: 1,
534 dimension: wgpu::TextureDimension::D2,
535 format: wgpu::TextureFormat::Rgba8UnormSrgb,
536 usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
537 view_formats: &[],
538 });
539 let view = texture.create_view(&wgpu::TextureViewDescriptor {
540 dimension: Some(wgpu::TextureViewDimension::D2Array),
541 ..Default::default()
542 });
543 let sampler = device.create_sampler(&sampler_desc(self.desc.filter));
544 let cam_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
545 label: Some("sprite_batch_cam_bgl"),
546 entries: &[wgpu::BindGroupLayoutEntry {
547 binding: 0,
548 visibility: wgpu::ShaderStages::VERTEX,
549 ty: wgpu::BindingType::Buffer {
550 ty: wgpu::BufferBindingType::Uniform,
551 has_dynamic_offset: false,
552 min_binding_size: None,
553 },
554 count: None,
555 }],
556 });
557 let tex_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
558 label: Some("sprite_batch_tex_bgl"),
559 entries: &[
560 wgpu::BindGroupLayoutEntry {
561 binding: 0,
562 visibility: wgpu::ShaderStages::FRAGMENT,
563 ty: wgpu::BindingType::Texture {
564 sample_type: wgpu::TextureSampleType::Float { filterable: true },
565 view_dimension: wgpu::TextureViewDimension::D2Array,
566 multisampled: false,
567 },
568 count: None,
569 },
570 wgpu::BindGroupLayoutEntry {
571 binding: 1,
572 visibility: wgpu::ShaderStages::FRAGMENT,
573 ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
574 count: None,
575 },
576 ],
577 });
578 let cam_bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
579 label: Some("sprite_batch_cam_bg"),
580 layout: &cam_layout,
581 entries: &[wgpu::BindGroupEntry {
582 binding: 0,
583 resource: camera.as_entire_binding(),
584 }],
585 });
586 let tex_bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
587 label: Some("sprite_batch_tex_bg"),
588 layout: &tex_layout,
589 entries: &[
590 wgpu::BindGroupEntry {
591 binding: 0,
592 resource: wgpu::BindingResource::TextureView(&view),
593 },
594 wgpu::BindGroupEntry {
595 binding: 1,
596 resource: wgpu::BindingResource::Sampler(&sampler),
597 },
598 ],
599 });
600 let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
601 label: Some("sprite_batch_pl"),
602 bind_group_layouts: &[Some(&cam_layout), Some(&tex_layout)],
603 immediate_size: 0,
604 });
605 let vertex_buffers = [
606 Some(wgpu::VertexBufferLayout {
607 array_stride: 8,
608 step_mode: wgpu::VertexStepMode::Vertex,
609 attributes: &[wgpu::VertexAttribute {
610 format: wgpu::VertexFormat::Float32x2,
611 offset: 0,
612 shader_location: 0,
613 }],
614 }),
615 Some(wgpu::VertexBufferLayout {
616 array_stride: size_of::<BatchInstance>() as u64,
617 step_mode: wgpu::VertexStepMode::Instance,
618 attributes: &[
619 wgpu::VertexAttribute {
620 format: wgpu::VertexFormat::Float32x4,
621 offset: 0,
622 shader_location: 2,
623 },
624 wgpu::VertexAttribute {
625 format: wgpu::VertexFormat::Float32x4,
626 offset: 16,
627 shader_location: 3,
628 },
629 wgpu::VertexAttribute {
630 format: wgpu::VertexFormat::Float32x2,
631 offset: 32,
632 shader_location: 4,
633 },
634 wgpu::VertexAttribute {
635 format: wgpu::VertexFormat::Float32x2,
636 offset: 40,
637 shader_location: 5,
638 },
639 wgpu::VertexAttribute {
640 format: wgpu::VertexFormat::Float32x4,
641 offset: 48,
642 shader_location: 6,
643 },
644 wgpu::VertexAttribute {
645 format: wgpu::VertexFormat::Float32,
646 offset: 64,
647 shader_location: 7,
648 },
649 wgpu::VertexAttribute {
650 format: wgpu::VertexFormat::Float32,
651 offset: 68,
652 shader_location: 8,
653 },
654 wgpu::VertexAttribute {
655 format: wgpu::VertexFormat::Uint32,
656 offset: 72,
657 shader_location: 9,
658 },
659 ],
660 }),
661 ];
662 let mk_pipeline = |label: &'static str, blend: wgpu::BlendState| {
663 device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
664 label: Some(label),
665 layout: Some(&pipeline_layout),
666 vertex: wgpu::VertexState {
667 module: &shader,
668 entry_point: Some("vs_main"),
669 buffers: &vertex_buffers,
670 compilation_options: Default::default(),
671 },
672 fragment: Some(wgpu::FragmentState {
673 module: &shader,
674 entry_point: Some("fs_main"),
675 targets: &[Some(wgpu::ColorTargetState {
676 format: screen.target_format,
677 blend: Some(blend),
678 write_mask: wgpu::ColorWrites::ALL,
679 })],
680 compilation_options: Default::default(),
681 }),
682 primitive: wgpu::PrimitiveState {
683 topology: wgpu::PrimitiveTopology::TriangleList,
684 ..Default::default()
685 },
686 depth_stencil: Some(wgpu::DepthStencilState {
687 format: wgpu::TextureFormat::Depth24PlusStencil8,
688 depth_write_enabled: Some(false),
689 depth_compare: Some(wgpu::CompareFunction::Always),
690 stencil: wgpu::StencilState {
691 front: wgpu::StencilFaceState {
692 compare: wgpu::CompareFunction::LessEqual,
693 ..Default::default()
694 },
695 back: wgpu::StencilFaceState {
696 compare: wgpu::CompareFunction::LessEqual,
697 ..Default::default()
698 },
699 ..Default::default()
700 },
701 bias: wgpu::DepthBiasState::default(),
702 }),
703 multisample: wgpu::MultisampleState {
704 count: screen.sample_count,
705 mask: !0,
706 alpha_to_coverage_enabled: false,
707 },
708 multiview_mask: None,
709 cache: None,
710 })
711 };
712 let pipeline_alpha = mk_pipeline("sprite_batch_pipeline", wgpu::BlendState::ALPHA_BLENDING);
713 let pipeline_multiply = mk_pipeline(
714 "sprite_batch_pipeline_mul",
715 wgpu::BlendState {
716 color: wgpu::BlendComponent {
717 src_factor: wgpu::BlendFactor::Dst,
718 dst_factor: wgpu::BlendFactor::Zero,
719 operation: wgpu::BlendOperation::Add,
720 },
721 alpha: wgpu::BlendComponent::OVER,
722 },
723 );
724 let pipeline_additive = mk_pipeline(
725 "sprite_batch_pipeline_add",
726 wgpu::BlendState {
727 color: wgpu::BlendComponent {
728 src_factor: wgpu::BlendFactor::SrcAlpha,
729 dst_factor: wgpu::BlendFactor::One,
730 operation: wgpu::BlendOperation::Add,
731 },
732 alpha: wgpu::BlendComponent {
733 src_factor: wgpu::BlendFactor::One,
734 dst_factor: wgpu::BlendFactor::One,
735 operation: wgpu::BlendOperation::Add,
736 },
737 },
738 );
739 let entry = BatchEntry {
740 key,
741 pipeline_alpha,
742 pipeline_multiply,
743 pipeline_additive,
744 corners,
745 instances,
746 instance_cap: self.instances.len().max(1),
747 last_alpha: 0,
748 last_multiply_end: 0,
749 last_total: 0,
750 applied_gen: u64::MAX,
751 camera,
752 cam_bind,
753 tex_bind,
754 texture,
755 };
756 match resources.get_mut::<BatchResources>() {
757 Some(all) => {
758 all.batches.insert(self.id.clone(), entry);
759 }
760 None => {
761 let mut all = BatchResources {
762 batches: HashMap::new(),
763 };
764 all.batches.insert(self.id.clone(), entry);
765 resources.insert(all);
766 }
767 }
768 }
769
770 fn finish_prepare(
774 &self,
775 alpha: u32,
776 multiply_end: u32,
777 total: u32,
778 resources: &mut CallbackResources,
779 ) {
780 if let Some(all) = resources.get_mut::<BatchResources>()
781 && let Some(res) = all.batches.get_mut(self.id.as_str())
782 {
783 res.last_alpha = alpha;
784 res.last_multiply_end = multiply_end;
785 res.last_total = total;
786 }
787 }
788
789 fn blend_group(flags: u32) -> u32 {
790 match flags {
791 1 => 2, 2 => 1, _ => 0, }
795 }
796
797 fn sorted_instances(&self) -> (Vec<BatchInstance>, u32, u32) {
806 let mut v: Vec<(usize, BatchInstance)> =
807 self.instances.iter().copied().enumerate().collect();
808 v.sort_by(|(ia, a), (ib, b)| {
809 Self::blend_group(a.flags)
810 .cmp(&Self::blend_group(b.flags))
811 .then_with(|| a.z.total_cmp(&b.z).then_with(|| ia.cmp(ib)))
812 });
813 let v: Vec<BatchInstance> = v.into_iter().map(|(_, inst)| inst).collect();
814 let alpha = v
815 .iter()
816 .take_while(|i| Self::blend_group(i.flags) == 0)
817 .count() as u32;
818 let multiply_end = alpha
819 + v[alpha as usize..]
820 .iter()
821 .take_while(|i| Self::blend_group(i.flags) == 1)
822 .count() as u32;
823 (v, alpha, multiply_end)
824 }
825}
826
827pub fn draw_batch_with_id(
830 id: &str,
831 rpass: &mut wgpu::RenderPass<'_>,
832 resources: &CallbackResources,
833) {
834 let Some(all) = resources.get::<BatchResources>() else {
835 return;
836 };
837 let Some(res) = all.batches.get(id) else {
838 return;
839 };
840 if res.last_total == 0 {
841 return;
842 }
843 rpass.set_bind_group(0, &res.cam_bind, &[]);
844 rpass.set_bind_group(1, &res.tex_bind, &[]);
845 rpass.set_vertex_buffer(0, res.corners.slice(..));
846 rpass.set_vertex_buffer(1, res.instances.slice(..));
847 if res.last_alpha > 0 {
848 rpass.set_pipeline(&res.pipeline_alpha);
849 rpass.draw(0..6, 0..res.last_alpha);
850 }
851 if res.last_multiply_end > res.last_alpha {
852 rpass.set_pipeline(&res.pipeline_multiply);
853 rpass.draw(0..6, res.last_alpha..res.last_multiply_end);
854 }
855 if res.last_total > res.last_multiply_end {
856 rpass.set_pipeline(&res.pipeline_additive);
857 rpass.draw(0..6, res.last_multiply_end..res.last_total);
858 }
859}
860
861pub fn draw_batch(rpass: &mut wgpu::RenderPass<'_>, resources: &CallbackResources) {
863 draw_batch_with_id("sprite_batch.default", rpass, resources);
864}
865
866impl WgpuCallback for SpriteBatch {
867 fn prepare(
868 &self,
869 device: &wgpu::Device,
870 queue: &wgpu::Queue,
871 _encoder: &mut wgpu::CommandEncoder,
872 screen: &ScreenDescriptor,
873 resources: &mut CallbackResources,
874 ) -> Vec<wgpu::CommandBuffer> {
875 self.ensure_resources(device, screen, resources);
876 let (sorted, alpha, multiply_end) = self.sorted_instances();
877 let total = sorted.len() as u32;
878 let Some(all) = resources.get_mut::<BatchResources>() else {
879 return Vec::new();
880 };
881 let Some(res) = all.batches.get_mut(self.id.as_str()) else {
882 return Vec::new();
883 };
884
885 if sorted.len() > res.instance_cap {
886 let new_cap = sorted.len().next_power_of_two().max(64);
887 res.instances = device.create_buffer(&wgpu::BufferDescriptor {
888 label: Some("sprite_batch_instances"),
889 size: (new_cap * size_of::<BatchInstance>()) as u64,
890 usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
891 mapped_at_creation: false,
892 });
893 res.instance_cap = new_cap;
894 }
895 queue.write_buffer(
896 &res.camera,
897 0,
898 bytemuck::cast_slice(&[CameraUniform {
899 view_proj: self.camera,
900 }]),
901 );
902 if !sorted.is_empty() {
903 queue.write_buffer(&res.instances, 0, bytemuck::cast_slice(&sorted));
904 }
905 let fresh_atlas = res.applied_gen != self.desc.uploads_gen;
908 if fresh_atlas {
909 res.applied_gen = self.desc.uploads_gen;
910 }
911 for up in &self.uploads {
912 if !fresh_atlas {
913 continue;
914 }
915 let expected = up.w as usize * up.h as usize * 4;
916 if up.page >= self.desc.layers
917 || up.x + up.w > self.desc.layer_size
918 || up.y + up.h > self.desc.layer_size
919 || up.rgba.len() != expected
920 {
921 log::warn!(
922 "sprite_batch: dropping out-of-range upload page={} {}x{}+{}+{} ({} bytes)",
923 up.page,
924 up.w,
925 up.h,
926 up.x,
927 up.y,
928 up.rgba.len()
929 );
930 continue;
931 }
932 queue.write_texture(
933 wgpu::TexelCopyTextureInfo {
934 texture: &res.texture,
935 mip_level: 0,
936 origin: wgpu::Origin3d {
937 x: up.x,
938 y: up.y,
939 z: up.page,
940 },
941 aspect: wgpu::TextureAspect::All,
942 },
943 &up.rgba,
944 wgpu::TexelCopyBufferLayout {
945 offset: 0,
946 bytes_per_row: Some(up.w * 4),
947 rows_per_image: Some(up.h),
948 },
949 wgpu::Extent3d {
950 width: up.w,
951 height: up.h,
952 depth_or_array_layers: 1,
953 },
954 );
955 }
956 self.finish_prepare(alpha, multiply_end, total, resources);
957 Vec::new()
958 }
959
960 fn paint(
961 &self,
962 _info: repose_core::PaintCallbackInfo,
963 rpass: &mut wgpu::RenderPass<'static>,
964 resources: &CallbackResources,
965 ) {
966 draw_batch_with_id(self.id.as_str(), rpass, resources);
967 }
968}
969
970#[cfg(test)]
971mod tests {
972 use super::*;
973
974 fn apply(rows: ([f32; 4], [f32; 4]), corner: [f32; 2]) -> [f32; 2] {
975 let (r0, r1) = rows;
976 [
977 r0[0] * corner[0] + r0[1] * corner[1] + r0[3],
978 r1[0] * corner[0] + r1[1] * corner[1] + r1[3],
979 ]
980 }
981
982 #[test]
983 fn default_rows_match_canvas_top_left() {
984 let rows = instance_rows([400.0, 300.0], [64.0, 80.0], 0.0, [0.5, 0.5], false, false);
985 let tl = apply(rows, [-0.5, -0.5]);
986 let br = apply(rows, [0.5, 0.5]);
987 assert_eq!(tl, [368.0, 260.0]);
988 assert_eq!(br, [432.0, 340.0]);
989 }
990
991 #[test]
992 fn frame_uv_indexes_row_major() {
993 let (mn, mx) = frame_uv(4, 2, 5);
995 assert_eq!(mn, [0.25, 0.5]);
996 assert_eq!(mx, [0.5, 1.0]);
997 assert_eq!(frame_uv(4, 2, 0).0, [0.0, 0.0]);
999 assert_eq!(frame_uv(4, 2, 99), frame_uv(4, 2, 7));
1000 assert_eq!(frame_uv(0, 0, 3), ([0.0, 0.0], [1.0, 1.0]));
1001 }
1002
1003 #[test]
1004 fn sprite_aabb_matches_unrotated_quad() {
1005 let (mn, mx) = sprite_aabb([400.0, 300.0], [64.0, 80.0], 0.0, [0.5, 0.5], false, false);
1006 assert_eq!(mn, [368.0, 260.0]);
1007 assert_eq!(mx, [432.0, 340.0]);
1008 let (mn, mx) = sprite_aabb(
1010 [0.0, 0.0],
1011 [64.0, 80.0],
1012 std::f32::consts::FRAC_PI_2,
1013 [0.5, 0.5],
1014 false,
1015 false,
1016 );
1017 assert!((mn[0] + 40.0).abs() < 1e-4 && (mn[1] + 32.0).abs() < 1e-4);
1018 assert!((mx[0] - 40.0).abs() < 1e-4 && (mx[1] - 32.0).abs() < 1e-4);
1019 }
1020
1021 #[test]
1022 fn quarter_turn_rotates_footprint() {
1023 let rows = instance_rows(
1024 [0.0, 0.0],
1025 [64.0, 80.0],
1026 std::f32::consts::FRAC_PI_2,
1027 [0.5, 0.5],
1028 false,
1029 false,
1030 );
1031 let p = apply(rows, [-0.5, -0.5]);
1033 assert!(
1034 (p[0] - 40.0).abs() < 1e-4 && (p[1] + 32.0).abs() < 1e-4,
1035 "got {p:?}"
1036 );
1037 }
1038
1039 #[test]
1040 fn top_left_anchor_pins_corner() {
1041 let rows = instance_rows([100.0, 100.0], [64.0, 80.0], 0.0, [0.0, 0.0], false, false);
1042 let tl = apply(rows, [-0.5, -0.5]);
1043 assert_eq!(tl, [100.0, 100.0]);
1044 }
1045
1046 #[test]
1047 fn flip_x_mirrors_around_anchor() {
1048 let plain = instance_rows([0.0, 0.0], [64.0, 8.0], 0.0, [0.5, 0.5], false, false);
1049 let flipped = instance_rows([0.0, 0.0], [64.0, 8.0], 0.0, [0.5, 0.5], true, false);
1050 let a = apply(plain, [-0.5, 0.0]);
1051 let b = apply(flipped, [-0.5, 0.0]);
1052 assert_eq!(a, [-32.0, 0.0]);
1053 assert_eq!(b, [32.0, 0.0]);
1054 }
1055
1056 #[test]
1057 fn screen_camera_is_y_down() {
1058 let cam = screen_camera([800.0, 600.0]);
1059 let top_left = cam.project_point3(glam::Vec3::new(0.0, 0.0, 0.0));
1060 let bottom_right = cam.project_point3(glam::Vec3::new(800.0, 600.0, 0.0));
1061 let center = cam.project_point3(glam::Vec3::new(400.0, 300.0, 0.0));
1062 assert!((top_left.x + 1.0).abs() < 1e-5 && (top_left.y - 1.0).abs() < 1e-5);
1064 assert!((bottom_right.x - 1.0).abs() < 1e-5 && (bottom_right.y + 1.0).abs() < 1e-5);
1065 assert!(center.x.abs() < 1e-5 && center.y.abs() < 1e-5);
1066 }
1067
1068 #[test]
1069 fn push_sprite_bridges_snapshot_fields() {
1070 let mut batch = SpriteBatch::new(BatchDesc::default());
1071 batch.push_sprite(&SpriteInstance {
1072 center: glam::Vec2::new(10.0, 20.0),
1073 size: glam::Vec2::new(4.0, 6.0),
1074 uv_min: glam::Vec2::new(0.25, 0.5),
1075 uv_max: glam::Vec2::new(0.5, 0.75),
1076 color: [1.0, 0.5, 0.25, 0.8],
1077 page: 3,
1078 ..Default::default()
1079 });
1080 assert_eq!(batch.len(), 1);
1081 let inst = batch.instances[0];
1082 assert_eq!(inst.uv_min, [0.25, 0.5]);
1083 assert_eq!(inst.uv_max, [0.5, 0.75]);
1084 assert_eq!(inst.tint, [1.0, 0.5, 0.25, 0.8]);
1085 assert_eq!(inst.page, 3.0);
1086 let p = apply((inst.row0, inst.row1), [-0.5, -0.5]);
1088 assert_eq!(p, [8.0, 17.0]);
1089 }
1090
1091 #[test]
1092 fn z_and_blend_sort_into_ranges() {
1093 use super::super::SpriteBlend;
1094 let mut batch = SpriteBatch::with_id("test.sort", BatchDesc::default());
1095 let quad = |z: f32, blend: SpriteBlend| SpriteInstance {
1096 center: glam::Vec2::new(0.0, 0.0),
1097 size: glam::Vec2::new(1.0, 1.0),
1098 z,
1099 blend,
1100 ..Default::default()
1101 };
1102 batch.push_sprite(&quad(5.0, SpriteBlend::Alpha));
1103 batch.push_sprite(&quad(1.0, SpriteBlend::Additive));
1104 batch.push_sprite(&quad(3.0, SpriteBlend::Multiply));
1105 batch.push_sprite(&quad(2.0, SpriteBlend::Alpha));
1106 batch.push_sprite(&quad(0.0, SpriteBlend::Multiply));
1107 let (sorted, alpha, mul_end) = batch.sorted_instances();
1108 assert_eq!((alpha, mul_end, sorted.len() as u32), (2, 4, 5));
1109 assert_eq!([sorted[0].z, sorted[1].z], [2.0, 5.0]);
1110 assert_eq!([sorted[2].z, sorted[3].z], [0.0, 3.0]);
1111 assert_eq!(sorted[4].z, 1.0);
1112 assert_eq!(sorted[4].flags, 1);
1113 }
1114
1115 #[test]
1120 fn same_z_keeps_push_order_across_pages() {
1121 use super::super::SpriteBlend;
1122 let mut batch = SpriteBatch::with_id("test.pages", BatchDesc::default());
1123 let quad = |page: u32| SpriteInstance {
1124 center: glam::Vec2::new(0.0, 0.0),
1125 size: glam::Vec2::new(1.0, 1.0),
1126 z: 0.0,
1127 blend: SpriteBlend::Alpha,
1128 page,
1129 ..Default::default()
1130 };
1131 batch.push_sprite(&quad(1));
1132 batch.push_sprite(&quad(0));
1133 let (sorted, _, _) = batch.sorted_instances();
1134 assert_eq!([sorted[0].page, sorted[1].page], [1.0, 0.0]);
1135 }
1136
1137 #[test]
1138 fn white_upload_is_1x1_white() {
1139 let up = AtlasUpload::white(2, 5, 7);
1140 assert_eq!((up.page, up.x, up.y, up.w, up.h), (2, 5, 7, 1, 1));
1141 assert_eq!(up.rgba, vec![255, 255, 255, 255]);
1142 }
1143
1144 #[test]
1149 fn offscreen_batch_draws_oriented_textured_quads() {
1150 use repose_core::{Color, Rect, Scene, SceneNode};
1151 use repose_render_wgpu::{Callback, offscreen::OffscreenRenderer};
1152
1153 let mut renderer = match OffscreenRenderer::new_blocking(256, 256, 1) {
1154 Ok(r) => r,
1155 Err(e) => {
1156 eprintln!("SKIP offscreen batch test (no GPU): {e}");
1157 return;
1158 }
1159 };
1160 let page0 = vec![
1162 255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 255, 255,
1166 ];
1167 let page1 = [255, 0, 255, 255].repeat(4);
1169 let mut batch = SpriteBatch::new(BatchDesc {
1170 layer_size: 2,
1171 layers: 2,
1172 filter: TextureFilter::Nearest,
1173 ..Default::default()
1174 });
1175 batch.upload(AtlasUpload {
1176 page: 0,
1177 x: 0,
1178 y: 0,
1179 w: 2,
1180 h: 2,
1181 rgba: page0,
1182 });
1183 batch.upload(AtlasUpload {
1184 page: 1,
1185 x: 0,
1186 y: 0,
1187 w: 2,
1188 h: 2,
1189 rgba: page1,
1190 });
1191 batch.set_camera(screen_camera([256.0, 256.0]));
1192 batch.push(
1194 [128.0, 128.0],
1195 [128.0, 128.0],
1196 0.0,
1197 [0.5, 0.5],
1198 false,
1199 false,
1200 [0.0, 0.0],
1201 [1.0, 1.0],
1202 [1.0, 1.0, 1.0, 1.0],
1203 0,
1204 );
1205 batch.push(
1207 [224.0, 224.0],
1208 [32.0, 32.0],
1209 0.0,
1210 [0.5, 0.5],
1211 false,
1212 false,
1213 [0.0, 0.0],
1214 [1.0, 1.0],
1215 [1.0, 1.0, 1.0, 1.0],
1216 1,
1217 );
1218 let scene = Scene {
1219 clear_color: Color::from_rgba(0, 0, 0, 255),
1220 nodes: vec![SceneNode::Callback {
1221 rect: Rect {
1222 x: 0.0,
1223 y: 0.0,
1224 w: 256.0,
1225 h: 256.0,
1226 },
1227 payload: Callback::new(batch),
1228 }],
1229 };
1230 let px = renderer
1231 .render_rgba(&scene, Some([0.0, 0.0, 0.0, 1.0]))
1232 .expect("offscreen render");
1233 let at = |x: u32, y: u32| -> [u8; 4] {
1234 let i = ((y * 256 + x) * 4) as usize;
1235 [px[i], px[i + 1], px[i + 2], px[i + 3]]
1236 };
1237 assert_eq!(at(64, 64), [255, 0, 0, 255], "top-left red");
1239 assert_eq!(at(191, 64), [0, 255, 0, 255], "top-right green");
1240 assert_eq!(at(64, 191), [0, 0, 255, 255], "bottom-left blue");
1241 assert_eq!(at(191, 191), [255, 255, 255, 255], "bottom-right white");
1242 assert_eq!(at(224, 224), [255, 0, 255, 255], "page 1 magenta");
1243 assert_eq!(at(8, 8), [0, 0, 0, 255], "background clear");
1244
1245 let mut flipped = SpriteBatch::new(BatchDesc {
1247 layer_size: 2,
1248 layers: 2,
1249 filter: TextureFilter::Nearest,
1250 ..Default::default()
1251 });
1252 flipped.set_camera(screen_camera([256.0, 256.0]));
1253 flipped.push(
1254 [128.0, 128.0],
1255 [128.0, 128.0],
1256 0.0,
1257 [0.5, 0.5],
1258 true,
1259 false,
1260 [0.0, 0.0],
1261 [1.0, 1.0],
1262 [1.0, 1.0, 1.0, 1.0],
1263 0,
1264 );
1265 let scene = Scene {
1266 clear_color: Color::from_rgba(0, 0, 0, 255),
1267 nodes: vec![SceneNode::Callback {
1268 rect: Rect {
1269 x: 0.0,
1270 y: 0.0,
1271 w: 256.0,
1272 h: 256.0,
1273 },
1274 payload: Callback::new(flipped),
1275 }],
1276 };
1277 let px = renderer
1278 .render_rgba(&scene, Some([0.0, 0.0, 0.0, 1.0]))
1279 .expect("offscreen render");
1280 let at = |x: u32, y: u32| -> [u8; 4] {
1281 let i = ((y * 256 + x) * 4) as usize;
1282 [px[i], px[i + 1], px[i + 2], px[i + 3]]
1283 };
1284 assert_eq!(at(64, 64), [0, 255, 0, 255], "flipped top-left green");
1285 assert_eq!(at(191, 191), [0, 0, 255, 255], "flipped bottom-right blue");
1286 }
1287}