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