1use std::collections::{HashMap, HashSet};
26use std::sync::{Arc, Mutex};
27
28use bytemuck::{Pod, Zeroable};
29use glam::Mat4;
30use repose_render_wgpu::{CallbackRenderPass, CallbackResources, ScreenDescriptor, WgpuCallback};
31use wgpu::util::DeviceExt;
32
33use super::{SpriteBlend, SpriteInstance};
34
35#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
37pub enum TextureFilter {
38 #[default]
40 Nearest,
41 Linear,
42}
43
44#[derive(Clone, Copy, Debug)]
46pub struct BatchDesc {
47 pub layer_size: u32,
49 pub layers: u32,
51 pub filter: TextureFilter,
52 pub uploads_gen: u64,
54}
55
56impl Default for BatchDesc {
57 fn default() -> Self {
58 Self {
59 layer_size: 2048,
60 layers: 4,
61 filter: TextureFilter::Nearest,
62 uploads_gen: 0,
63 }
64 }
65}
66
67#[derive(Clone, Debug)]
70pub struct AtlasUpload {
71 pub page: u32,
72 pub x: u32,
73 pub y: u32,
74 pub w: u32,
75 pub h: u32,
76 pub rgba: Vec<u8>,
77}
78
79impl AtlasUpload {
80 pub fn from_write(write: &repame_atlas::AtlasWrite, rgba: Vec<u8>) -> Self {
84 Self {
85 page: write.page,
86 x: write.x,
87 y: write.y,
88 w: write.w,
89 h: write.h,
90 rgba,
91 }
92 }
93
94 pub fn white(page: u32, x: u32, y: u32) -> Self {
99 Self {
100 page,
101 x,
102 y,
103 w: 1,
104 h: 1,
105 rgba: vec![255, 255, 255, 255],
106 }
107 }
108
109 pub fn white_for(write: &repame_atlas::AtlasWrite) -> Self {
112 Self {
113 page: write.page,
114 x: write.x,
115 y: write.y,
116 w: write.w.max(1),
117 h: write.h.max(1),
118 rgba: vec![255; write.w.max(1) as usize * write.h.max(1) as usize * 4],
119 }
120 }
121}
122
123pub fn instance_rows(
128 center: [f32; 2],
129 size: [f32; 2],
130 rotation: f32,
131 anchor: [f32; 2],
132 flip_x: bool,
133 flip_y: bool,
134) -> ([f32; 4], [f32; 4]) {
135 let (c, s) = (rotation.cos(), rotation.sin());
136 let sx = if flip_x { -size[0] } else { size[0] };
137 let sy = if flip_y { -size[1] } else { size[1] };
138 let (ax, ay) = (0.5 - anchor[0], 0.5 - anchor[1]);
141 (
142 [c * sx, -s * sy, 0.0, center[0] + c * sx * ax - s * sy * ay],
143 [s * sx, c * sy, 0.0, center[1] + s * sx * ax + c * sy * ay],
144 )
145}
146
147pub fn frame_uv(hframes: u32, vframes: u32, frame: u32) -> ([f32; 2], [f32; 2]) {
169 let hf = hframes.max(1);
170 let vf = vframes.max(1);
171 let f = frame.min(hf * vf - 1);
172 let col = f % hf;
173 let row = f / hf;
174 (
175 [col as f32 / hf as f32, row as f32 / vf as f32],
176 [(col + 1) as f32 / hf as f32, (row + 1) as f32 / vf as f32],
177 )
178}
179
180pub fn sprite_aabb(
198 center: [f32; 2],
199 size: [f32; 2],
200 rotation: f32,
201 anchor: [f32; 2],
202 flip_x: bool,
203 flip_y: bool,
204) -> ([f32; 2], [f32; 2]) {
205 let (row0, row1) = instance_rows(center, size, rotation, anchor, flip_x, flip_y);
206 let mut min = [f32::INFINITY, f32::INFINITY];
207 let mut max = [f32::NEG_INFINITY, f32::NEG_INFINITY];
208 for corner in [[-0.5, -0.5], [0.5, -0.5], [0.5, 0.5], [-0.5, 0.5]] {
209 let x = row0[0] * corner[0] + row0[1] * corner[1] + row0[3];
210 let y = row1[0] * corner[0] + row1[1] * corner[1] + row1[3];
211 min[0] = min[0].min(x);
212 min[1] = min[1].min(y);
213 max[0] = max[0].max(x);
214 max[1] = max[1].max(y);
215 }
216 (min, max)
217}
218
219pub fn screen_camera(viewport_px: [f32; 2]) -> Mat4 {
224 glam::camera::rh::proj::directx::orthographic(
226 0.0,
227 viewport_px[0].max(1.0),
228 viewport_px[1].max(1.0),
229 0.0,
230 -1000.0,
231 1000.0,
232 )
233}
234
235#[repr(C)]
236#[derive(Clone, Copy, Pod, Zeroable)]
237struct CameraUniform {
238 view_proj: [[f32; 4]; 4],
239}
240
241#[repr(C)]
242#[derive(Clone, Copy, Pod, Zeroable)]
243struct BatchInstance {
244 row0: [f32; 4],
245 row1: [f32; 4],
246 uv_min: [f32; 2],
247 uv_max: [f32; 2],
248 tint: [f32; 4],
249 page: f32,
250 z: f32,
251 flags: u32,
252 texel: f32,
255}
256
257const _: () = assert!(size_of::<BatchInstance>() == 80);
263
264pub struct SpriteBatch {
272 id: String,
273 desc: BatchDesc,
274 camera: [[f32; 4]; 4],
275 instances: Vec<BatchInstance>,
276 uploads: Vec<AtlasUpload>,
277}
278
279impl SpriteBatch {
280 pub fn new(desc: BatchDesc) -> Self {
281 Self::with_id("sprite_batch.default", desc)
282 }
283
284 pub fn with_id(id: impl Into<String>, desc: BatchDesc) -> Self {
285 Self {
286 id: id.into(),
287 desc,
288 camera: Mat4::IDENTITY.to_cols_array_2d(),
289 instances: Vec::new(),
290 uploads: Vec::new(),
291 }
292 }
293
294 pub fn id(&self) -> &str {
295 &self.id
296 }
297
298 pub fn clear(&mut self) {
299 self.instances.clear();
300 self.uploads.clear();
301 }
302
303 pub fn len(&self) -> usize {
304 self.instances.len()
305 }
306
307 pub fn is_empty(&self) -> bool {
308 self.instances.is_empty()
309 }
310
311 pub fn set_camera(&mut self, view_proj: Mat4) {
314 self.camera = view_proj.to_cols_array_2d();
315 }
316
317 pub fn upload(&mut self, upload: AtlasUpload) {
319 self.uploads.push(upload);
320 }
321
322 pub fn extend_uploads(&mut self, uploads: impl IntoIterator<Item = AtlasUpload>) {
324 self.uploads.extend(uploads);
325 }
326
327 #[allow(clippy::too_many_arguments)]
329 pub fn push(
330 &mut self,
331 center: [f32; 2],
332 size: [f32; 2],
333 rotation: f32,
334 anchor: [f32; 2],
335 flip_x: bool,
336 flip_y: bool,
337 uv_min: [f32; 2],
338 uv_max: [f32; 2],
339 tint: [f32; 4],
340 page: u32,
341 ) {
342 self.push_blended(
343 center,
344 size,
345 rotation,
346 anchor,
347 flip_x,
348 flip_y,
349 uv_min,
350 uv_max,
351 tint,
352 page,
353 0.0,
354 SpriteBlend::Alpha,
355 );
356 }
357
358 #[allow(clippy::too_many_arguments)]
360 pub fn push_blended(
361 &mut self,
362 center: [f32; 2],
363 size: [f32; 2],
364 rotation: f32,
365 anchor: [f32; 2],
366 flip_x: bool,
367 flip_y: bool,
368 uv_min: [f32; 2],
369 uv_max: [f32; 2],
370 tint: [f32; 4],
371 page: u32,
372 z: f32,
373 blend: SpriteBlend,
374 ) {
375 let (row0, row1) = instance_rows(center, size, rotation, anchor, flip_x, flip_y);
376 let flags = match blend {
377 SpriteBlend::Alpha => 0,
378 SpriteBlend::Additive => 1,
379 SpriteBlend::Multiply => 2,
380 };
381 self.instances.push(BatchInstance {
382 row0,
383 row1,
384 uv_min,
385 uv_max,
386 tint,
387 page: page as f32,
388 z,
389 flags,
390 texel: 0.5 / self.desc.layer_size.max(1) as f32,
391 });
392 }
393
394 pub fn push_sprite(&mut self, s: &SpriteInstance) {
401 self.push_blended(
402 [s.center.x, s.center.y],
403 [s.size.x, s.size.y],
404 s.rotation,
405 [s.anchor.x, s.anchor.y],
406 s.flip_x,
407 s.flip_y,
408 [s.uv_min.x, s.uv_min.y],
409 [s.uv_max.x, s.uv_max.y],
410 super::rgba8(s.color).to_linear(),
411 s.page,
412 s.z,
413 s.blend,
414 );
415 }
416}
417
418#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
419struct AtlasDescriptor {
420 layer_size: u32,
421 layers: u32,
422 filter: TextureFilter,
423}
424
425#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
426struct AtlasKey {
427 descriptor: AtlasDescriptor,
428 generation: u64,
429}
430
431struct AtlasLayout {
432 tex_layout: wgpu::BindGroupLayout,
433 sampler: wgpu::Sampler,
434}
435
436struct AtlasResources {
437 texture: wgpu::Texture,
438 tex_bind: wgpu::BindGroup,
439 applied_gen: Arc<Mutex<Option<u64>>>,
440 starved_gen: Arc<Mutex<Option<u64>>>,
443}
444
445struct BatchEntry {
446 key: (wgpu::TextureFormat, u32, u32, u32, TextureFilter),
447 pipeline_alpha: wgpu::RenderPipeline,
448 pipeline_multiply: wgpu::RenderPipeline,
449 pipeline_additive: wgpu::RenderPipeline,
450 corners: wgpu::Buffer,
451 instances: wgpu::Buffer,
452 instance_cap: usize,
453 last_alpha: u32,
456 last_multiply_end: u32,
457 last_total: u32,
458 camera: wgpu::Buffer,
459 cam_bind: wgpu::BindGroup,
460 atlas: Arc<AtlasResources>,
461 atlas_key: AtlasKey,
462}
463
464struct BatchResources {
465 batches: HashMap<String, BatchEntry>,
466 atlases: HashMap<AtlasKey, Arc<AtlasResources>>,
467 layouts: HashMap<AtlasDescriptor, Arc<AtlasLayout>>,
468}
469
470impl BatchResources {
471 fn prune_atlases(&mut self) {
472 let live: HashSet<AtlasKey> = self.batches.values().map(|entry| entry.atlas_key).collect();
473 self.atlases.retain(|key, _| live.contains(key));
474 }
475}
476
477const CORNERS: &[f32] = &[
478 -0.5, -0.5, 0.5, -0.5, 0.5, 0.5, -0.5, -0.5, 0.5, 0.5, -0.5, 0.5, ];
485
486fn sampler_desc(filter: TextureFilter) -> wgpu::SamplerDescriptor<'static> {
487 let f = match filter {
488 TextureFilter::Nearest => wgpu::FilterMode::Nearest,
489 TextureFilter::Linear => wgpu::FilterMode::Linear,
490 };
491 let m = match filter {
492 TextureFilter::Nearest => wgpu::MipmapFilterMode::Nearest,
493 TextureFilter::Linear => wgpu::MipmapFilterMode::Linear,
494 };
495 wgpu::SamplerDescriptor {
496 label: Some("sprite_batch_sampler"),
497 address_mode_u: wgpu::AddressMode::ClampToEdge,
498 address_mode_v: wgpu::AddressMode::ClampToEdge,
499 address_mode_w: wgpu::AddressMode::ClampToEdge,
500 mag_filter: f,
501 min_filter: f,
502 mipmap_filter: m,
503 lod_min_clamp: 0.0,
504 lod_max_clamp: 1.0,
505 compare: None,
506 anisotropy_clamp: 1,
507 border_color: None,
508 }
509}
510
511pub(crate) fn atlas_shape(layers: u32) -> (wgpu::TextureViewDimension, bool) {
519 if layers <= 1 {
520 (wgpu::TextureViewDimension::D2, false)
521 } else {
522 (wgpu::TextureViewDimension::D2Array, true)
523 }
524}
525
526pub(crate) fn sprite_shader(layers: u32) -> &'static str {
527 if atlas_shape(layers).1 {
528 include_str!("../shaders/sprite.wgsl")
529 } else {
530 include_str!("../shaders/sprite_single.wgsl")
531 }
532}
533
534fn create_atlas_layout(device: &wgpu::Device, filter: TextureFilter, layers: u32) -> AtlasLayout {
535 let (dimension, _) = atlas_shape(layers);
536 let sampler = device.create_sampler(&sampler_desc(filter));
537 let tex_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
538 label: Some("sprite_batch_tex_bgl"),
539 entries: &[
540 wgpu::BindGroupLayoutEntry {
541 binding: 0,
542 visibility: wgpu::ShaderStages::FRAGMENT,
543 ty: wgpu::BindingType::Texture {
544 sample_type: wgpu::TextureSampleType::Float { filterable: true },
545 view_dimension: dimension,
546 multisampled: false,
547 },
548 count: None,
549 },
550 wgpu::BindGroupLayoutEntry {
551 binding: 1,
552 visibility: wgpu::ShaderStages::FRAGMENT,
553 ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
554 count: None,
555 },
556 ],
557 });
558 AtlasLayout {
559 tex_layout,
560 sampler,
561 }
562}
563
564fn create_atlas(device: &wgpu::Device, key: AtlasKey, layout: &AtlasLayout) -> AtlasResources {
565 let descriptor = key.descriptor;
566 let texture = device.create_texture(&wgpu::TextureDescriptor {
567 label: Some("sprite_batch_atlas"),
568 size: wgpu::Extent3d {
569 width: descriptor.layer_size,
570 height: descriptor.layer_size,
571 depth_or_array_layers: descriptor.layers,
572 },
573 mip_level_count: 1,
574 sample_count: 1,
575 dimension: wgpu::TextureDimension::D2,
576 format: wgpu::TextureFormat::Rgba8UnormSrgb,
577 usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
578 view_formats: &[],
579 });
580 let (dimension, _) = atlas_shape(descriptor.layers);
581 let view = texture.create_view(&wgpu::TextureViewDescriptor {
582 dimension: Some(dimension),
583 ..Default::default()
584 });
585 let tex_bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
586 label: Some("sprite_batch_tex_bg"),
587 layout: &layout.tex_layout,
588 entries: &[
589 wgpu::BindGroupEntry {
590 binding: 0,
591 resource: wgpu::BindingResource::TextureView(&view),
592 },
593 wgpu::BindGroupEntry {
594 binding: 1,
595 resource: wgpu::BindingResource::Sampler(&layout.sampler),
596 },
597 ],
598 });
599 AtlasResources {
600 texture,
601 tex_bind,
602 applied_gen: Arc::new(Mutex::new(None)),
603 starved_gen: Arc::new(Mutex::new(None)),
604 }
605}
606
607impl SpriteBatch {
608 fn ensure_resources(
609 &self,
610 device: &wgpu::Device,
611 screen: &ScreenDescriptor,
612 resources: &mut CallbackResources,
613 ) {
614 let descriptor = AtlasDescriptor {
615 layer_size: self.desc.layer_size,
616 layers: self.desc.layers,
617 filter: self.desc.filter,
618 };
619 let atlas_key = AtlasKey {
620 descriptor,
621 generation: self.desc.uploads_gen,
622 };
623 let key = (
624 screen.target_format,
625 screen.sample_count,
626 self.desc.layer_size,
627 self.desc.layers,
628 self.desc.filter,
629 );
630 if resources.get::<BatchResources>().is_none() {
631 resources.insert(BatchResources {
632 batches: HashMap::new(),
633 atlases: HashMap::new(),
634 layouts: HashMap::new(),
635 });
636 }
637 let layout = resources
638 .get_mut::<BatchResources>()
639 .expect("batch resources initialized")
640 .layouts
641 .entry(descriptor)
642 .or_insert_with(|| {
643 Arc::new(create_atlas_layout(
644 device,
645 descriptor.filter,
646 descriptor.layers,
647 ))
648 })
649 .clone();
650 let atlas = resources
651 .get_mut::<BatchResources>()
652 .expect("batch resources initialized")
653 .atlases
654 .entry(atlas_key)
655 .or_insert_with(|| Arc::new(create_atlas(device, atlas_key, &layout)))
656 .clone();
657 let needs = resources
658 .get::<BatchResources>()
659 .expect("batch resources initialized")
660 .batches
661 .get(self.id.as_str())
662 .is_none_or(|entry| entry.key != key);
663 if !needs {
664 if let Some(all) = resources.get_mut::<BatchResources>()
665 && let Some(entry) = all.batches.get_mut(self.id.as_str())
666 {
667 entry.atlas = atlas;
668 entry.atlas_key = atlas_key;
669 all.prune_atlases();
670 }
671 return;
672 }
673 if resources
674 .get::<BatchResources>()
675 .is_some_and(|all| all.batches.contains_key(self.id.as_str()))
676 {
677 log::warn!(
678 "sprite_batch[{}]: rebuilding pipeline for format/sample/descriptor change",
679 self.id
680 );
681 }
682 let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
683 label: Some("sprite_batch"),
684 source: wgpu::ShaderSource::Wgsl(sprite_shader(self.desc.layers).into()),
685 });
686 let corners = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
687 label: Some("sprite_batch_corners"),
688 contents: bytemuck::cast_slice(CORNERS),
689 usage: wgpu::BufferUsages::VERTEX,
690 });
691 let camera = device.create_buffer(&wgpu::BufferDescriptor {
692 label: Some("sprite_batch_camera"),
693 size: 64,
694 usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
695 mapped_at_creation: false,
696 });
697 let instances = device.create_buffer(&wgpu::BufferDescriptor {
698 label: Some("sprite_batch_instances"),
699 size: (self.instances.len().max(1) * size_of::<BatchInstance>()) as u64,
700 usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
701 mapped_at_creation: false,
702 });
703 let cam_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
704 label: Some("sprite_batch_cam_bgl"),
705 entries: &[wgpu::BindGroupLayoutEntry {
706 binding: 0,
707 visibility: wgpu::ShaderStages::VERTEX,
708 ty: wgpu::BindingType::Buffer {
709 ty: wgpu::BufferBindingType::Uniform,
710 has_dynamic_offset: false,
711 min_binding_size: None,
712 },
713 count: None,
714 }],
715 });
716 let cam_bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
717 label: Some("sprite_batch_cam_bg"),
718 layout: &cam_layout,
719 entries: &[wgpu::BindGroupEntry {
720 binding: 0,
721 resource: camera.as_entire_binding(),
722 }],
723 });
724 let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
725 label: Some("sprite_batch_pl"),
726 bind_group_layouts: &[Some(&cam_layout), Some(&layout.tex_layout)],
727 immediate_size: 0,
728 });
729 let vertex_buffers = [
730 Some(wgpu::VertexBufferLayout {
731 array_stride: 8,
732 step_mode: wgpu::VertexStepMode::Vertex,
733 attributes: &[wgpu::VertexAttribute {
734 format: wgpu::VertexFormat::Float32x2,
735 offset: 0,
736 shader_location: 0,
737 }],
738 }),
739 Some(wgpu::VertexBufferLayout {
740 array_stride: size_of::<BatchInstance>() as u64,
741 step_mode: wgpu::VertexStepMode::Instance,
742 attributes: &[
743 wgpu::VertexAttribute {
744 format: wgpu::VertexFormat::Float32x4,
745 offset: 0,
746 shader_location: 2,
747 },
748 wgpu::VertexAttribute {
749 format: wgpu::VertexFormat::Float32x4,
750 offset: 16,
751 shader_location: 3,
752 },
753 wgpu::VertexAttribute {
754 format: wgpu::VertexFormat::Float32x2,
755 offset: 32,
756 shader_location: 4,
757 },
758 wgpu::VertexAttribute {
759 format: wgpu::VertexFormat::Float32x2,
760 offset: 40,
761 shader_location: 5,
762 },
763 wgpu::VertexAttribute {
764 format: wgpu::VertexFormat::Float32x4,
765 offset: 48,
766 shader_location: 6,
767 },
768 wgpu::VertexAttribute {
769 format: wgpu::VertexFormat::Float32,
770 offset: 64,
771 shader_location: 7,
772 },
773 wgpu::VertexAttribute {
774 format: wgpu::VertexFormat::Float32,
775 offset: 68,
776 shader_location: 8,
777 },
778 wgpu::VertexAttribute {
779 format: wgpu::VertexFormat::Uint32,
780 offset: 72,
781 shader_location: 9,
782 },
783 wgpu::VertexAttribute {
784 format: wgpu::VertexFormat::Float32,
785 offset: 76,
786 shader_location: 10,
787 },
788 ],
789 }),
790 ];
791 let mk_pipeline = |label: &'static str, blend: wgpu::BlendState| {
792 device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
793 label: Some(label),
794 layout: Some(&pipeline_layout),
795 vertex: wgpu::VertexState {
796 module: &shader,
797 entry_point: Some("vs_main"),
798 buffers: &vertex_buffers,
799 compilation_options: Default::default(),
800 },
801 fragment: Some(wgpu::FragmentState {
802 module: &shader,
803 entry_point: Some("fs_main"),
804 targets: &[Some(wgpu::ColorTargetState {
805 format: screen.target_format,
806 blend: Some(blend),
807 write_mask: wgpu::ColorWrites::ALL,
808 })],
809 compilation_options: Default::default(),
810 }),
811 primitive: wgpu::PrimitiveState {
812 topology: wgpu::PrimitiveTopology::TriangleList,
813 ..Default::default()
814 },
815 depth_stencil: Some(wgpu::DepthStencilState {
816 format: wgpu::TextureFormat::Depth24PlusStencil8,
817 depth_write_enabled: Some(false),
818 depth_compare: Some(wgpu::CompareFunction::Always),
819 stencil: wgpu::StencilState {
820 front: wgpu::StencilFaceState {
821 compare: wgpu::CompareFunction::LessEqual,
822 ..Default::default()
823 },
824 back: wgpu::StencilFaceState {
825 compare: wgpu::CompareFunction::LessEqual,
826 ..Default::default()
827 },
828 ..Default::default()
829 },
830 bias: wgpu::DepthBiasState::default(),
831 }),
832 multisample: wgpu::MultisampleState {
833 count: screen.sample_count,
834 mask: !0,
835 alpha_to_coverage_enabled: false,
836 },
837 multiview_mask: None,
838 cache: None,
839 })
840 };
841 let pipeline_alpha = mk_pipeline("sprite_batch_pipeline", wgpu::BlendState::ALPHA_BLENDING);
842 let pipeline_multiply = mk_pipeline(
843 "sprite_batch_pipeline_mul",
844 wgpu::BlendState {
845 color: wgpu::BlendComponent {
846 src_factor: wgpu::BlendFactor::Dst,
847 dst_factor: wgpu::BlendFactor::Zero,
848 operation: wgpu::BlendOperation::Add,
849 },
850 alpha: wgpu::BlendComponent::OVER,
851 },
852 );
853 let pipeline_additive = mk_pipeline(
854 "sprite_batch_pipeline_add",
855 wgpu::BlendState {
856 color: wgpu::BlendComponent {
857 src_factor: wgpu::BlendFactor::SrcAlpha,
858 dst_factor: wgpu::BlendFactor::One,
859 operation: wgpu::BlendOperation::Add,
860 },
861 alpha: wgpu::BlendComponent {
862 src_factor: wgpu::BlendFactor::One,
863 dst_factor: wgpu::BlendFactor::One,
864 operation: wgpu::BlendOperation::Add,
865 },
866 },
867 );
868 let entry = BatchEntry {
869 key,
870 pipeline_alpha,
871 pipeline_multiply,
872 pipeline_additive,
873 corners,
874 instances,
875 instance_cap: self.instances.len().max(1),
876 last_alpha: 0,
877 last_multiply_end: 0,
878 last_total: 0,
879 camera,
880 cam_bind,
881 atlas,
882 atlas_key,
883 };
884 resources
885 .get_mut::<BatchResources>()
886 .expect("batch resources initialized")
887 .batches
888 .insert(self.id.clone(), entry);
889 resources
890 .get_mut::<BatchResources>()
891 .expect("batch resources initialized")
892 .prune_atlases();
893 }
894
895 fn finish_prepare(
899 &self,
900 alpha: u32,
901 multiply_end: u32,
902 total: u32,
903 resources: &mut CallbackResources,
904 ) {
905 if let Some(all) = resources.get_mut::<BatchResources>()
906 && let Some(res) = all.batches.get_mut(self.id.as_str())
907 {
908 res.last_alpha = alpha;
909 res.last_multiply_end = multiply_end;
910 res.last_total = total;
911 }
912 }
913
914 fn blend_group(flags: u32) -> u32 {
915 match flags {
916 1 => 2, 2 => 1, _ => 0, }
920 }
921
922 fn sorted_instances(&self) -> (Vec<BatchInstance>, u32, u32) {
931 let mut v: Vec<(usize, BatchInstance)> =
932 self.instances.iter().copied().enumerate().collect();
933 v.sort_by(|(ia, a), (ib, b)| {
934 Self::blend_group(a.flags)
935 .cmp(&Self::blend_group(b.flags))
936 .then_with(|| a.z.total_cmp(&b.z).then_with(|| ia.cmp(ib)))
937 });
938 let v: Vec<BatchInstance> = v.into_iter().map(|(_, inst)| inst).collect();
939 let alpha = v
940 .iter()
941 .take_while(|i| Self::blend_group(i.flags) == 0)
942 .count() as u32;
943 let multiply_end = alpha
944 + v[alpha as usize..]
945 .iter()
946 .take_while(|i| Self::blend_group(i.flags) == 1)
947 .count() as u32;
948 (v, alpha, multiply_end)
949 }
950
951 pub fn prepare_with_uploads(
954 &self,
955 device: &wgpu::Device,
956 queue: &wgpu::Queue,
957 _encoder: &mut wgpu::CommandEncoder,
958 screen: &ScreenDescriptor,
959 resources: &mut CallbackResources,
960 uploads: &[AtlasUpload],
961 ) -> Vec<wgpu::CommandBuffer> {
962 self.ensure_resources(device, screen, resources);
963 let (sorted, alpha, multiply_end) = self.sorted_instances();
964 let total = sorted.len() as u32;
965 let Some(all) = resources.get_mut::<BatchResources>() else {
966 return Vec::new();
967 };
968 let Some(res) = all.batches.get_mut(self.id.as_str()) else {
969 return Vec::new();
970 };
971
972 if sorted.len() > res.instance_cap {
973 let new_cap = sorted.len().next_power_of_two().max(64);
974 res.instances = device.create_buffer(&wgpu::BufferDescriptor {
975 label: Some("sprite_batch_instances"),
976 size: (new_cap * size_of::<BatchInstance>()) as u64,
977 usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
978 mapped_at_creation: false,
979 });
980 res.instance_cap = new_cap;
981 }
982 queue.write_buffer(
983 &res.camera,
984 0,
985 bytemuck::cast_slice(&[CameraUniform {
986 view_proj: self.camera,
987 }]),
988 );
989 if !sorted.is_empty() {
990 queue.write_buffer(&res.instances, 0, bytemuck::cast_slice(&sorted));
991 }
992 let atlas = res.atlas.clone();
993 let atlas_behind = atlas
996 .applied_gen
997 .lock()
998 .map(|generation| *generation != Some(self.desc.uploads_gen))
999 .unwrap_or(true);
1000 if atlas_behind && uploads.is_empty() {
1001 self.report_atlas_starved(&atlas);
1002 self.finish_prepare(alpha, multiply_end, total, resources);
1003 return Vec::new();
1004 }
1005 let mut wrote_any = false;
1006 for up in uploads {
1007 if !atlas_behind {
1008 continue;
1009 }
1010 let expected = up.w as usize * up.h as usize * 4;
1011 if up.page >= self.desc.layers
1012 || up.x + up.w > self.desc.layer_size
1013 || up.y + up.h > self.desc.layer_size
1014 || up.rgba.len() != expected
1015 {
1016 log::warn!(
1017 "sprite_batch: dropping out-of-range upload page={} {}x{}+{}+{} ({} bytes)",
1018 up.page,
1019 up.w,
1020 up.h,
1021 up.x,
1022 up.y,
1023 up.rgba.len()
1024 );
1025 continue;
1026 }
1027 queue.write_texture(
1028 wgpu::TexelCopyTextureInfo {
1029 texture: &atlas.texture,
1030 mip_level: 0,
1031 origin: wgpu::Origin3d {
1032 x: up.x,
1033 y: up.y,
1034 z: up.page,
1035 },
1036 aspect: wgpu::TextureAspect::All,
1037 },
1038 &up.rgba,
1039 wgpu::TexelCopyBufferLayout {
1040 offset: 0,
1041 bytes_per_row: Some(up.w * 4),
1042 rows_per_image: Some(up.h),
1043 },
1044 wgpu::Extent3d {
1045 width: up.w,
1046 height: up.h,
1047 depth_or_array_layers: 1,
1048 },
1049 );
1050 wrote_any = true;
1051 }
1052 if atlas_behind
1053 && wrote_any
1054 && let Ok(mut generation) = atlas.applied_gen.lock()
1055 {
1056 *generation = Some(self.desc.uploads_gen);
1057 }
1058 self.finish_prepare(alpha, multiply_end, total, resources);
1059 Vec::new()
1060 }
1061
1062 fn report_atlas_starved(&self, atlas: &AtlasResources) {
1065 let Ok(mut reported) = atlas.starved_gen.lock() else {
1066 return;
1067 };
1068 if *reported == Some(self.desc.uploads_gen) {
1069 return;
1070 }
1071 *reported = Some(self.desc.uploads_gen);
1072 log::error!(
1073 "sprite_batch[{}]: atlas generation {} is unfilled and no uploads arrived, \
1074 drawing nothing; pass that generation's uploads on every frame until \
1075 the batch applies them, because the first frame carrying them may be \
1076 built before the view is prepared and then discarded",
1077 self.id,
1078 self.desc.uploads_gen
1079 );
1080 }
1081}
1082
1083trait BatchRenderPass {
1084 fn set_bind_group(&mut self, index: u32, bind_group: &wgpu::BindGroup, offsets: &[u32]);
1085 fn set_vertex_buffer(&mut self, slot: u32, buffer: wgpu::BufferSlice<'_>);
1086 fn set_pipeline(&mut self, pipeline: &wgpu::RenderPipeline);
1087 fn draw(&mut self, vertices: std::ops::Range<u32>, instances: std::ops::Range<u32>);
1088}
1089
1090impl BatchRenderPass for wgpu::RenderPass<'_> {
1091 fn set_bind_group(&mut self, index: u32, bind_group: &wgpu::BindGroup, offsets: &[u32]) {
1092 self.set_bind_group(index, bind_group, offsets);
1093 }
1094
1095 fn set_vertex_buffer(&mut self, slot: u32, buffer: wgpu::BufferSlice<'_>) {
1096 self.set_vertex_buffer(slot, buffer);
1097 }
1098
1099 fn set_pipeline(&mut self, pipeline: &wgpu::RenderPipeline) {
1100 self.set_pipeline(pipeline);
1101 }
1102
1103 fn draw(&mut self, vertices: std::ops::Range<u32>, instances: std::ops::Range<u32>) {
1104 self.draw(vertices, instances);
1105 }
1106}
1107
1108impl BatchRenderPass for CallbackRenderPass<'_, '_> {
1109 fn set_bind_group(&mut self, index: u32, bind_group: &wgpu::BindGroup, offsets: &[u32]) {
1110 self.set_bind_group(index, bind_group, offsets);
1111 }
1112
1113 fn set_vertex_buffer(&mut self, slot: u32, buffer: wgpu::BufferSlice<'_>) {
1114 self.set_vertex_buffer(slot, buffer);
1115 }
1116
1117 fn set_pipeline(&mut self, pipeline: &wgpu::RenderPipeline) {
1118 self.set_pipeline(pipeline);
1119 }
1120
1121 fn draw(&mut self, vertices: std::ops::Range<u32>, instances: std::ops::Range<u32>) {
1122 self.draw(vertices, instances);
1123 }
1124}
1125
1126fn draw_batch_with_pass<P: BatchRenderPass>(
1127 id: &str,
1128 rpass: &mut P,
1129 resources: &CallbackResources,
1130) {
1131 let Some(all) = resources.get::<BatchResources>() else {
1132 return;
1133 };
1134 let Some(res) = all.batches.get(id) else {
1135 return;
1136 };
1137 if res.last_total == 0 {
1138 return;
1139 }
1140 rpass.set_bind_group(0, &res.cam_bind, &[]);
1141 rpass.set_bind_group(1, &res.atlas.tex_bind, &[]);
1142 rpass.set_vertex_buffer(0, res.corners.slice(..));
1143 rpass.set_vertex_buffer(1, res.instances.slice(..));
1144 if res.last_alpha > 0 {
1145 rpass.set_pipeline(&res.pipeline_alpha);
1146 rpass.draw(0..6, 0..res.last_alpha);
1147 }
1148 if res.last_multiply_end > res.last_alpha {
1149 rpass.set_pipeline(&res.pipeline_multiply);
1150 rpass.draw(0..6, res.last_alpha..res.last_multiply_end);
1151 }
1152 if res.last_total > res.last_multiply_end {
1153 rpass.set_pipeline(&res.pipeline_additive);
1154 rpass.draw(0..6, res.last_multiply_end..res.last_total);
1155 }
1156}
1157
1158pub fn draw_batch_with_id(
1161 id: &str,
1162 rpass: &mut wgpu::RenderPass<'_>,
1163 resources: &CallbackResources,
1164) {
1165 draw_batch_with_pass(id, rpass, resources);
1166}
1167
1168pub(crate) fn draw_batch_with_id_callback(
1169 id: &str,
1170 rpass: &mut CallbackRenderPass<'_, '_>,
1171 resources: &CallbackResources,
1172) {
1173 draw_batch_with_pass(id, rpass, resources);
1174}
1175
1176pub fn draw_batch(rpass: &mut wgpu::RenderPass<'_>, resources: &CallbackResources) {
1178 draw_batch_with_id("sprite_batch.default", rpass, resources);
1179}
1180
1181impl WgpuCallback for SpriteBatch {
1182 fn resource_key(&self) -> Option<&str> {
1183 Some("repame.sprite_batch.resources")
1184 }
1185 fn prepare(
1186 &self,
1187 device: &wgpu::Device,
1188 queue: &wgpu::Queue,
1189 encoder: &mut wgpu::CommandEncoder,
1190 screen: &ScreenDescriptor,
1191 resources: &mut CallbackResources,
1192 ) -> Vec<wgpu::CommandBuffer> {
1193 self.prepare_with_uploads(device, queue, encoder, screen, resources, &self.uploads)
1194 }
1195
1196 fn paint(
1197 &self,
1198 _info: repose_core::PaintCallbackInfo,
1199 rpass: &mut CallbackRenderPass<'_, '_>,
1200 resources: &CallbackResources,
1201 ) {
1202 draw_batch_with_id_callback(self.id.as_str(), rpass, resources);
1203 }
1204}
1205
1206pub struct BatchFrame {
1211 batch: SpriteBatch,
1212 uploads: std::sync::Arc<[AtlasUpload]>,
1213}
1214
1215impl BatchFrame {
1216 pub fn new(batch: SpriteBatch, uploads: std::sync::Arc<[AtlasUpload]>) -> Self {
1217 Self { batch, uploads }
1218 }
1219
1220 pub fn node(self, rect: repose_core::Rect) -> repose_core::SceneNode {
1221 repose_core::SceneNode::Callback {
1222 rect,
1223 payload: repose_render_wgpu::Callback::new(self),
1224 }
1225 }
1226}
1227
1228impl WgpuCallback for BatchFrame {
1229 fn resource_key(&self) -> Option<&str> {
1230 Some("repame.sprite_batch.resources")
1231 }
1232
1233 fn prepare(
1234 &self,
1235 device: &wgpu::Device,
1236 queue: &wgpu::Queue,
1237 encoder: &mut wgpu::CommandEncoder,
1238 screen: &ScreenDescriptor,
1239 resources: &mut CallbackResources,
1240 ) -> Vec<wgpu::CommandBuffer> {
1241 self.batch
1242 .prepare_with_uploads(device, queue, encoder, screen, resources, &self.uploads)
1243 }
1244
1245 fn paint(
1246 &self,
1247 _info: repose_core::PaintCallbackInfo,
1248 rpass: &mut CallbackRenderPass<'_, '_>,
1249 resources: &CallbackResources,
1250 ) {
1251 draw_batch_with_id_callback(&self.batch.id, rpass, resources);
1252 }
1253}
1254
1255#[cfg(test)]
1256mod tests {
1257 use super::*;
1258
1259 fn apply(rows: ([f32; 4], [f32; 4]), corner: [f32; 2]) -> [f32; 2] {
1260 let (r0, r1) = rows;
1261 [
1262 r0[0] * corner[0] + r0[1] * corner[1] + r0[3],
1263 r1[0] * corner[0] + r1[1] * corner[1] + r1[3],
1264 ]
1265 }
1266
1267 #[test]
1268 fn default_rows_match_canvas_top_left() {
1269 let rows = instance_rows([400.0, 300.0], [64.0, 80.0], 0.0, [0.5, 0.5], false, false);
1270 let tl = apply(rows, [-0.5, -0.5]);
1271 let br = apply(rows, [0.5, 0.5]);
1272 assert_eq!(tl, [368.0, 260.0]);
1273 assert_eq!(br, [432.0, 340.0]);
1274 }
1275
1276 #[test]
1277 fn frame_uv_indexes_row_major() {
1278 let (mn, mx) = frame_uv(4, 2, 5);
1280 assert_eq!(mn, [0.25, 0.5]);
1281 assert_eq!(mx, [0.5, 1.0]);
1282 assert_eq!(frame_uv(4, 2, 0).0, [0.0, 0.0]);
1284 assert_eq!(frame_uv(4, 2, 99), frame_uv(4, 2, 7));
1285 assert_eq!(frame_uv(0, 0, 3), ([0.0, 0.0], [1.0, 1.0]));
1286 }
1287
1288 #[test]
1289 fn sprite_aabb_matches_unrotated_quad() {
1290 let (mn, mx) = sprite_aabb([400.0, 300.0], [64.0, 80.0], 0.0, [0.5, 0.5], false, false);
1291 assert_eq!(mn, [368.0, 260.0]);
1292 assert_eq!(mx, [432.0, 340.0]);
1293 let (mn, mx) = sprite_aabb(
1295 [0.0, 0.0],
1296 [64.0, 80.0],
1297 std::f32::consts::FRAC_PI_2,
1298 [0.5, 0.5],
1299 false,
1300 false,
1301 );
1302 assert!((mn[0] + 40.0).abs() < 1e-4 && (mn[1] + 32.0).abs() < 1e-4);
1303 assert!((mx[0] - 40.0).abs() < 1e-4 && (mx[1] - 32.0).abs() < 1e-4);
1304 }
1305
1306 #[test]
1307 fn quarter_turn_rotates_footprint() {
1308 let rows = instance_rows(
1309 [0.0, 0.0],
1310 [64.0, 80.0],
1311 std::f32::consts::FRAC_PI_2,
1312 [0.5, 0.5],
1313 false,
1314 false,
1315 );
1316 let p = apply(rows, [-0.5, -0.5]);
1318 assert!(
1319 (p[0] - 40.0).abs() < 1e-4 && (p[1] + 32.0).abs() < 1e-4,
1320 "got {p:?}"
1321 );
1322 }
1323
1324 #[test]
1325 fn top_left_anchor_pins_corner() {
1326 let rows = instance_rows([100.0, 100.0], [64.0, 80.0], 0.0, [0.0, 0.0], false, false);
1327 let tl = apply(rows, [-0.5, -0.5]);
1328 assert_eq!(tl, [100.0, 100.0]);
1329 }
1330
1331 #[test]
1332 fn flip_x_mirrors_around_anchor() {
1333 let plain = instance_rows([0.0, 0.0], [64.0, 8.0], 0.0, [0.5, 0.5], false, false);
1334 let flipped = instance_rows([0.0, 0.0], [64.0, 8.0], 0.0, [0.5, 0.5], true, false);
1335 let a = apply(plain, [-0.5, 0.0]);
1336 let b = apply(flipped, [-0.5, 0.0]);
1337 assert_eq!(a, [-32.0, 0.0]);
1338 assert_eq!(b, [32.0, 0.0]);
1339 }
1340
1341 #[test]
1342 fn screen_camera_is_y_down() {
1343 let cam = screen_camera([800.0, 600.0]);
1344 let top_left = cam.project_point3(glam::Vec3::new(0.0, 0.0, 0.0));
1345 let bottom_right = cam.project_point3(glam::Vec3::new(800.0, 600.0, 0.0));
1346 let center = cam.project_point3(glam::Vec3::new(400.0, 300.0, 0.0));
1347 assert!((top_left.x + 1.0).abs() < 1e-5 && (top_left.y - 1.0).abs() < 1e-5);
1349 assert!((bottom_right.x - 1.0).abs() < 1e-5 && (bottom_right.y + 1.0).abs() < 1e-5);
1350 assert!(center.x.abs() < 1e-5 && center.y.abs() < 1e-5);
1351 }
1352
1353 #[test]
1354 fn push_sprite_bridges_snapshot_fields() {
1355 let mut batch = SpriteBatch::new(BatchDesc::default());
1356 batch.push_sprite(&SpriteInstance {
1357 center: glam::Vec2::new(10.0, 20.0),
1358 size: glam::Vec2::new(4.0, 6.0),
1359 uv_min: glam::Vec2::new(0.25, 0.5),
1360 uv_max: glam::Vec2::new(0.5, 0.75),
1361 color: [1.0, 0.5, 0.25, 0.8],
1362 page: 3,
1363 ..Default::default()
1364 });
1365 assert_eq!(batch.len(), 1);
1366 let inst = batch.instances[0];
1367 assert_eq!(inst.uv_min, [0.25, 0.5]);
1368 assert_eq!(inst.uv_max, [0.5, 0.75]);
1369 assert_eq!(
1371 inst.tint,
1372 crate::rgba8([1.0, 0.5, 0.25, 0.8]).to_linear(),
1373 "tint reaches the batch in linear space"
1374 );
1375 assert_eq!(inst.page, 3.0);
1376 let p = apply((inst.row0, inst.row1), [-0.5, -0.5]);
1378 assert_eq!(p, [8.0, 17.0]);
1379 }
1380
1381 #[test]
1382 fn z_and_blend_sort_into_ranges() {
1383 use super::super::SpriteBlend;
1384 let mut batch = SpriteBatch::with_id("test.sort", BatchDesc::default());
1385 let quad = |z: f32, blend: SpriteBlend| SpriteInstance {
1386 center: glam::Vec2::new(0.0, 0.0),
1387 size: glam::Vec2::new(1.0, 1.0),
1388 z,
1389 blend,
1390 ..Default::default()
1391 };
1392 batch.push_sprite(&quad(5.0, SpriteBlend::Alpha));
1393 batch.push_sprite(&quad(1.0, SpriteBlend::Additive));
1394 batch.push_sprite(&quad(3.0, SpriteBlend::Multiply));
1395 batch.push_sprite(&quad(2.0, SpriteBlend::Alpha));
1396 batch.push_sprite(&quad(0.0, SpriteBlend::Multiply));
1397 let (sorted, alpha, mul_end) = batch.sorted_instances();
1398 assert_eq!((alpha, mul_end, sorted.len() as u32), (2, 4, 5));
1399 assert_eq!([sorted[0].z, sorted[1].z], [2.0, 5.0]);
1400 assert_eq!([sorted[2].z, sorted[3].z], [0.0, 3.0]);
1401 assert_eq!(sorted[4].z, 1.0);
1402 assert_eq!(sorted[4].flags, 1);
1403 }
1404
1405 #[test]
1410 fn same_z_keeps_push_order_across_pages() {
1411 use super::super::SpriteBlend;
1412 let mut batch = SpriteBatch::with_id("test.pages", BatchDesc::default());
1413 let quad = |page: u32| SpriteInstance {
1414 center: glam::Vec2::new(0.0, 0.0),
1415 size: glam::Vec2::new(1.0, 1.0),
1416 z: 0.0,
1417 blend: SpriteBlend::Alpha,
1418 page,
1419 ..Default::default()
1420 };
1421 batch.push_sprite(&quad(1));
1422 batch.push_sprite(&quad(0));
1423 let (sorted, _, _) = batch.sorted_instances();
1424 assert_eq!([sorted[0].page, sorted[1].page], [1.0, 0.0]);
1425 }
1426
1427 #[test]
1428 fn white_upload_is_1x1_white() {
1429 let up = AtlasUpload::white(2, 5, 7);
1430 assert_eq!((up.page, up.x, up.y, up.w, up.h), (2, 5, 7, 1, 1));
1431 assert_eq!(up.rgba, vec![255, 255, 255, 255]);
1432 }
1433
1434 #[test]
1435 fn single_layer_atlas_draws_tinted_sprites() {
1436 assert_eq!(
1442 crate::batch::atlas_shape(1),
1443 (wgpu::TextureViewDimension::D2, false)
1444 );
1445 assert_eq!(
1446 crate::batch::atlas_shape(4),
1447 (wgpu::TextureViewDimension::D2Array, true)
1448 );
1449 assert!(
1450 crate::batch::sprite_shader(1).contains("texture_2d<")
1451 && !crate::batch::sprite_shader(1).contains("texture_2d_array"),
1452 "single-layer shader samples a 2d atlas"
1453 );
1454 assert!(
1455 crate::batch::sprite_shader(4).contains("texture_2d_array"),
1456 "multi-layer shader samples an array atlas"
1457 );
1458 }
1459
1460 #[test]
1465 fn offscreen_batch_draws_oriented_textured_quads() {
1466 use repose_core::{Color, Rect, Scene, SceneNode};
1467 use repose_render_wgpu::{Callback, offscreen::OffscreenRenderer};
1468
1469 let mut renderer = match OffscreenRenderer::new_blocking(256, 256, 1) {
1470 Ok(r) => r,
1471 Err(e) => {
1472 eprintln!("SKIP offscreen batch test (no GPU): {e}");
1473 return;
1474 }
1475 };
1476 let page0 = vec![
1478 255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 255, 255,
1482 ];
1483 let page1 = [255, 0, 255, 255].repeat(4);
1485 let mut batch = SpriteBatch::new(BatchDesc {
1486 layer_size: 2,
1487 layers: 2,
1488 filter: TextureFilter::Nearest,
1489 ..Default::default()
1490 });
1491 batch.upload(AtlasUpload {
1492 page: 0,
1493 x: 0,
1494 y: 0,
1495 w: 2,
1496 h: 2,
1497 rgba: page0,
1498 });
1499 batch.upload(AtlasUpload {
1500 page: 1,
1501 x: 0,
1502 y: 0,
1503 w: 2,
1504 h: 2,
1505 rgba: page1,
1506 });
1507 batch.set_camera(screen_camera([256.0, 256.0]));
1508 batch.push(
1510 [128.0, 128.0],
1511 [128.0, 128.0],
1512 0.0,
1513 [0.5, 0.5],
1514 false,
1515 false,
1516 [0.0, 0.0],
1517 [1.0, 1.0],
1518 [1.0, 1.0, 1.0, 1.0],
1519 0,
1520 );
1521 batch.push(
1523 [224.0, 224.0],
1524 [32.0, 32.0],
1525 0.0,
1526 [0.5, 0.5],
1527 false,
1528 false,
1529 [0.0, 0.0],
1530 [1.0, 1.0],
1531 [1.0, 1.0, 1.0, 1.0],
1532 1,
1533 );
1534 let scene = Scene {
1535 clear_color: Color::from_rgba(0, 0, 0, 255),
1536 nodes: vec![SceneNode::Callback {
1537 rect: Rect {
1538 x: 0.0,
1539 y: 0.0,
1540 w: 256.0,
1541 h: 256.0,
1542 },
1543 payload: Callback::new(batch),
1544 }],
1545 };
1546 let px = renderer
1547 .render_rgba(&scene, Some([0.0, 0.0, 0.0, 1.0]))
1548 .expect("offscreen render");
1549 let at = |x: u32, y: u32| -> [u8; 4] {
1550 let i = ((y * 256 + x) * 4) as usize;
1551 [px[i], px[i + 1], px[i + 2], px[i + 3]]
1552 };
1553 assert_eq!(at(64, 64), [255, 0, 0, 255], "top-left red");
1555 assert_eq!(at(191, 64), [0, 255, 0, 255], "top-right green");
1556 assert_eq!(at(64, 191), [0, 0, 255, 255], "bottom-left blue");
1557 assert_eq!(at(191, 191), [255, 255, 255, 255], "bottom-right white");
1558 assert_eq!(at(224, 224), [255, 0, 255, 255], "page 1 magenta");
1559 assert_eq!(at(8, 8), [0, 0, 0, 255], "background clear");
1560
1561 let mut flipped = SpriteBatch::new(BatchDesc {
1563 layer_size: 2,
1564 layers: 2,
1565 filter: TextureFilter::Nearest,
1566 ..Default::default()
1567 });
1568 flipped.set_camera(screen_camera([256.0, 256.0]));
1569 flipped.push(
1570 [128.0, 128.0],
1571 [128.0, 128.0],
1572 0.0,
1573 [0.5, 0.5],
1574 true,
1575 false,
1576 [0.0, 0.0],
1577 [1.0, 1.0],
1578 [1.0, 1.0, 1.0, 1.0],
1579 0,
1580 );
1581 let scene = Scene {
1582 clear_color: Color::from_rgba(0, 0, 0, 255),
1583 nodes: vec![SceneNode::Callback {
1584 rect: Rect {
1585 x: 0.0,
1586 y: 0.0,
1587 w: 256.0,
1588 h: 256.0,
1589 },
1590 payload: Callback::new(flipped),
1591 }],
1592 };
1593 let px = renderer
1594 .render_rgba(&scene, Some([0.0, 0.0, 0.0, 1.0]))
1595 .expect("offscreen render");
1596 let at = |x: u32, y: u32| -> [u8; 4] {
1597 let i = ((y * 256 + x) * 4) as usize;
1598 [px[i], px[i + 1], px[i + 2], px[i + 3]]
1599 };
1600 assert_eq!(at(64, 64), [0, 255, 0, 255], "flipped top-left green");
1601 assert_eq!(at(191, 191), [0, 0, 255, 255], "flipped bottom-right blue");
1602 }
1603
1604 #[test]
1609 fn distinct_batch_ids_compose_in_one_scene() {
1610 use repose_core::{Color, Rect, Scene};
1611 use repose_render_wgpu::offscreen::OffscreenRenderer;
1612 use std::sync::Arc;
1613
1614 let mut renderer = match OffscreenRenderer::new_blocking(256, 256, 1) {
1615 Ok(r) => r,
1616 Err(e) => {
1617 eprintln!("SKIP batch compose test (no GPU): {e}");
1618 return;
1619 }
1620 };
1621 let page = vec![
1622 255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 255, 255,
1626 ];
1627 let uploads: Arc<[AtlasUpload]> = Arc::from(vec![AtlasUpload {
1628 page: 0,
1629 x: 0,
1630 y: 0,
1631 w: 2,
1632 h: 2,
1633 rgba: page,
1634 }]);
1635 let desc = BatchDesc {
1636 layer_size: 2,
1637 layers: 1,
1638 filter: TextureFilter::Nearest,
1639 ..Default::default()
1640 };
1641 let mut left = SpriteBatch::with_id("test.compose.left", desc.clone());
1642 left.set_camera(screen_camera([256.0, 256.0]));
1643 left.push(
1644 [64.0, 128.0],
1645 [64.0, 64.0],
1646 0.0,
1647 [0.5, 0.5],
1648 false,
1649 false,
1650 [0.0, 0.0],
1651 [1.0, 1.0],
1652 [1.0, 1.0, 1.0, 1.0],
1653 0,
1654 );
1655 let mut right = SpriteBatch::with_id("test.compose.right", desc.clone());
1656 right.set_camera(screen_camera([256.0, 256.0]));
1657 right.push(
1658 [192.0, 128.0],
1659 [64.0, 64.0],
1660 0.0,
1661 [0.5, 0.5],
1662 false,
1663 false,
1664 [0.0, 0.0],
1665 [1.0, 1.0],
1666 [1.0, 1.0, 1.0, 1.0],
1667 0,
1668 );
1669 let full = Rect {
1670 x: 0.0,
1671 y: 0.0,
1672 w: 256.0,
1673 h: 256.0,
1674 };
1675 let scene = Scene {
1676 clear_color: Color::from_rgba(0, 0, 0, 255),
1677 nodes: vec![
1678 BatchFrame::new(left, uploads.clone()).node(full),
1679 BatchFrame::new(right, uploads.clone()).node(full),
1680 ],
1681 };
1682 let px = renderer
1683 .render_rgba(&scene, Some([0.0, 0.0, 0.0, 1.0]))
1684 .expect("offscreen render");
1685 let at = |x: u32, y: u32| -> [u8; 4] {
1686 let i = ((y * 256 + x) * 4) as usize;
1687 [px[i], px[i + 1], px[i + 2], px[i + 3]]
1688 };
1689 assert_eq!(at(40, 104), [255, 0, 0, 255], "left run red");
1690 assert_eq!(at(168, 104), [255, 0, 0, 255], "right run red");
1691 }
1692}