use std::collections::{HashMap, HashSet};
use std::sync::{Arc, Mutex};
use bytemuck::{Pod, Zeroable};
use glam::Mat4;
use repose_render_wgpu::{CallbackRenderPass, CallbackResources, ScreenDescriptor, WgpuCallback};
use wgpu::util::DeviceExt;
use super::{SpriteBlend, SpriteInstance};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum TextureFilter {
#[default]
Nearest,
Linear,
}
#[derive(Clone, Copy, Debug)]
pub struct BatchDesc {
pub layer_size: u32,
pub layers: u32,
pub filter: TextureFilter,
pub uploads_gen: u64,
}
impl Default for BatchDesc {
fn default() -> Self {
Self {
layer_size: 2048,
layers: 4,
filter: TextureFilter::Nearest,
uploads_gen: 0,
}
}
}
#[derive(Clone, Debug)]
pub struct AtlasUpload {
pub page: u32,
pub x: u32,
pub y: u32,
pub w: u32,
pub h: u32,
pub rgba: Vec<u8>,
}
impl AtlasUpload {
pub fn from_write(write: &repame_atlas::AtlasWrite, rgba: Vec<u8>) -> Self {
Self {
page: write.page,
x: write.x,
y: write.y,
w: write.w,
h: write.h,
rgba,
}
}
pub fn white(page: u32, x: u32, y: u32) -> Self {
Self {
page,
x,
y,
w: 1,
h: 1,
rgba: vec![255, 255, 255, 255],
}
}
pub fn white_for(write: &repame_atlas::AtlasWrite) -> Self {
Self {
page: write.page,
x: write.x,
y: write.y,
w: write.w.max(1),
h: write.h.max(1),
rgba: vec![255; write.w.max(1) as usize * write.h.max(1) as usize * 4],
}
}
}
pub fn instance_rows(
center: [f32; 2],
size: [f32; 2],
rotation: f32,
anchor: [f32; 2],
flip_x: bool,
flip_y: bool,
) -> ([f32; 4], [f32; 4]) {
let (c, s) = (rotation.cos(), rotation.sin());
let sx = if flip_x { -size[0] } else { size[0] };
let sy = if flip_y { -size[1] } else { size[1] };
let (ax, ay) = (0.5 - anchor[0], 0.5 - anchor[1]);
(
[c * sx, -s * sy, 0.0, center[0] + c * sx * ax - s * sy * ay],
[s * sx, c * sy, 0.0, center[1] + s * sx * ax + c * sy * ay],
)
}
pub fn frame_uv(hframes: u32, vframes: u32, frame: u32) -> ([f32; 2], [f32; 2]) {
let hf = hframes.max(1);
let vf = vframes.max(1);
let f = frame.min(hf * vf - 1);
let col = f % hf;
let row = f / hf;
(
[col as f32 / hf as f32, row as f32 / vf as f32],
[(col + 1) as f32 / hf as f32, (row + 1) as f32 / vf as f32],
)
}
pub fn sprite_aabb(
center: [f32; 2],
size: [f32; 2],
rotation: f32,
anchor: [f32; 2],
flip_x: bool,
flip_y: bool,
) -> ([f32; 2], [f32; 2]) {
let (row0, row1) = instance_rows(center, size, rotation, anchor, flip_x, flip_y);
let mut min = [f32::INFINITY, f32::INFINITY];
let mut max = [f32::NEG_INFINITY, f32::NEG_INFINITY];
for corner in [[-0.5, -0.5], [0.5, -0.5], [0.5, 0.5], [-0.5, 0.5]] {
let x = row0[0] * corner[0] + row0[1] * corner[1] + row0[3];
let y = row1[0] * corner[0] + row1[1] * corner[1] + row1[3];
min[0] = min[0].min(x);
min[1] = min[1].min(y);
max[0] = max[0].max(x);
max[1] = max[1].max(y);
}
(min, max)
}
pub fn screen_camera(viewport_px: [f32; 2]) -> Mat4 {
glam::camera::rh::proj::directx::orthographic(
0.0,
viewport_px[0].max(1.0),
viewport_px[1].max(1.0),
0.0,
-1000.0,
1000.0,
)
}
#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable)]
struct CameraUniform {
view_proj: [[f32; 4]; 4],
}
#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable)]
struct BatchInstance {
row0: [f32; 4],
row1: [f32; 4],
uv_min: [f32; 2],
uv_max: [f32; 2],
tint: [f32; 4],
page: f32,
z: f32,
flags: u32,
texel: f32,
}
const _: () = assert!(size_of::<BatchInstance>() == 80);
pub struct SpriteBatch {
id: String,
desc: BatchDesc,
camera: [[f32; 4]; 4],
instances: Vec<BatchInstance>,
uploads: Vec<AtlasUpload>,
}
impl SpriteBatch {
pub fn new(desc: BatchDesc) -> Self {
Self::with_id("sprite_batch.default", desc)
}
pub fn with_id(id: impl Into<String>, desc: BatchDesc) -> Self {
Self {
id: id.into(),
desc,
camera: Mat4::IDENTITY.to_cols_array_2d(),
instances: Vec::new(),
uploads: Vec::new(),
}
}
pub fn id(&self) -> &str {
&self.id
}
pub fn clear(&mut self) {
self.instances.clear();
self.uploads.clear();
}
pub fn len(&self) -> usize {
self.instances.len()
}
pub fn is_empty(&self) -> bool {
self.instances.is_empty()
}
pub fn set_camera(&mut self, view_proj: Mat4) {
self.camera = view_proj.to_cols_array_2d();
}
pub fn upload(&mut self, upload: AtlasUpload) {
self.uploads.push(upload);
}
pub fn extend_uploads(&mut self, uploads: impl IntoIterator<Item = AtlasUpload>) {
self.uploads.extend(uploads);
}
#[allow(clippy::too_many_arguments)]
pub fn push(
&mut self,
center: [f32; 2],
size: [f32; 2],
rotation: f32,
anchor: [f32; 2],
flip_x: bool,
flip_y: bool,
uv_min: [f32; 2],
uv_max: [f32; 2],
tint: [f32; 4],
page: u32,
) {
self.push_blended(
center,
size,
rotation,
anchor,
flip_x,
flip_y,
uv_min,
uv_max,
tint,
page,
0.0,
SpriteBlend::Alpha,
);
}
#[allow(clippy::too_many_arguments)]
pub fn push_blended(
&mut self,
center: [f32; 2],
size: [f32; 2],
rotation: f32,
anchor: [f32; 2],
flip_x: bool,
flip_y: bool,
uv_min: [f32; 2],
uv_max: [f32; 2],
tint: [f32; 4],
page: u32,
z: f32,
blend: SpriteBlend,
) {
let (row0, row1) = instance_rows(center, size, rotation, anchor, flip_x, flip_y);
let flags = match blend {
SpriteBlend::Alpha => 0,
SpriteBlend::Additive => 1,
SpriteBlend::Multiply => 2,
};
self.instances.push(BatchInstance {
row0,
row1,
uv_min,
uv_max,
tint,
page: page as f32,
z,
flags,
texel: 0.5 / self.desc.layer_size.max(1) as f32,
});
}
pub fn push_sprite(&mut self, s: &SpriteInstance) {
self.push_blended(
[s.center.x, s.center.y],
[s.size.x, s.size.y],
s.rotation,
[s.anchor.x, s.anchor.y],
s.flip_x,
s.flip_y,
[s.uv_min.x, s.uv_min.y],
[s.uv_max.x, s.uv_max.y],
s.color,
s.page,
s.z,
s.blend,
);
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
struct AtlasDescriptor {
layer_size: u32,
layers: u32,
filter: TextureFilter,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
struct AtlasKey {
descriptor: AtlasDescriptor,
generation: u64,
}
struct AtlasLayout {
tex_layout: wgpu::BindGroupLayout,
sampler: wgpu::Sampler,
}
struct AtlasResources {
texture: wgpu::Texture,
tex_bind: wgpu::BindGroup,
applied_gen: Arc<Mutex<Option<u64>>>,
}
struct BatchEntry {
key: (wgpu::TextureFormat, u32, u32, u32, TextureFilter),
pipeline_alpha: wgpu::RenderPipeline,
pipeline_multiply: wgpu::RenderPipeline,
pipeline_additive: wgpu::RenderPipeline,
corners: wgpu::Buffer,
instances: wgpu::Buffer,
instance_cap: usize,
last_alpha: u32,
last_multiply_end: u32,
last_total: u32,
camera: wgpu::Buffer,
cam_bind: wgpu::BindGroup,
atlas: Arc<AtlasResources>,
atlas_key: AtlasKey,
}
struct BatchResources {
batches: HashMap<String, BatchEntry>,
atlases: HashMap<AtlasKey, Arc<AtlasResources>>,
layouts: HashMap<AtlasDescriptor, Arc<AtlasLayout>>,
}
impl BatchResources {
fn prune_atlases(&mut self) {
let live: HashSet<AtlasKey> = self.batches.values().map(|entry| entry.atlas_key).collect();
self.atlases.retain(|key, _| live.contains(key));
}
}
const CORNERS: &[f32] = &[
-0.5, -0.5, 0.5, -0.5, 0.5, 0.5, -0.5, -0.5, 0.5, 0.5, -0.5, 0.5, ];
fn sampler_desc(filter: TextureFilter) -> wgpu::SamplerDescriptor<'static> {
let f = match filter {
TextureFilter::Nearest => wgpu::FilterMode::Nearest,
TextureFilter::Linear => wgpu::FilterMode::Linear,
};
let m = match filter {
TextureFilter::Nearest => wgpu::MipmapFilterMode::Nearest,
TextureFilter::Linear => wgpu::MipmapFilterMode::Linear,
};
wgpu::SamplerDescriptor {
label: Some("sprite_batch_sampler"),
address_mode_u: wgpu::AddressMode::ClampToEdge,
address_mode_v: wgpu::AddressMode::ClampToEdge,
address_mode_w: wgpu::AddressMode::ClampToEdge,
mag_filter: f,
min_filter: f,
mipmap_filter: m,
lod_min_clamp: 0.0,
lod_max_clamp: 1.0,
compare: None,
anisotropy_clamp: 1,
border_color: None,
}
}
fn create_atlas_layout(device: &wgpu::Device, filter: TextureFilter) -> AtlasLayout {
let sampler = device.create_sampler(&sampler_desc(filter));
let tex_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("sprite_batch_tex_bgl"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2Array,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
],
});
AtlasLayout {
tex_layout,
sampler,
}
}
fn create_atlas(device: &wgpu::Device, key: AtlasKey, layout: &AtlasLayout) -> AtlasResources {
let descriptor = key.descriptor;
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("sprite_batch_atlas"),
size: wgpu::Extent3d {
width: descriptor.layer_size,
height: descriptor.layer_size,
depth_or_array_layers: descriptor.layers,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8UnormSrgb,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
let view = texture.create_view(&wgpu::TextureViewDescriptor {
dimension: Some(wgpu::TextureViewDimension::D2Array),
..Default::default()
});
let tex_bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("sprite_batch_tex_bg"),
layout: &layout.tex_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&view),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(&layout.sampler),
},
],
});
AtlasResources {
texture,
tex_bind,
applied_gen: Arc::new(Mutex::new(None)),
}
}
impl SpriteBatch {
fn ensure_resources(
&self,
device: &wgpu::Device,
screen: &ScreenDescriptor,
resources: &mut CallbackResources,
) {
let descriptor = AtlasDescriptor {
layer_size: self.desc.layer_size,
layers: self.desc.layers,
filter: self.desc.filter,
};
let atlas_key = AtlasKey {
descriptor,
generation: self.desc.uploads_gen,
};
let key = (
screen.target_format,
screen.sample_count,
self.desc.layer_size,
self.desc.layers,
self.desc.filter,
);
if resources.get::<BatchResources>().is_none() {
resources.insert(BatchResources {
batches: HashMap::new(),
atlases: HashMap::new(),
layouts: HashMap::new(),
});
}
let layout = resources
.get_mut::<BatchResources>()
.expect("batch resources initialized")
.layouts
.entry(descriptor)
.or_insert_with(|| Arc::new(create_atlas_layout(device, descriptor.filter)))
.clone();
let atlas = resources
.get_mut::<BatchResources>()
.expect("batch resources initialized")
.atlases
.entry(atlas_key)
.or_insert_with(|| Arc::new(create_atlas(device, atlas_key, &layout)))
.clone();
let needs = resources
.get::<BatchResources>()
.expect("batch resources initialized")
.batches
.get(self.id.as_str())
.is_none_or(|entry| entry.key != key);
if !needs {
if let Some(all) = resources.get_mut::<BatchResources>()
&& let Some(entry) = all.batches.get_mut(self.id.as_str())
{
entry.atlas = atlas;
entry.atlas_key = atlas_key;
all.prune_atlases();
}
return;
}
if resources
.get::<BatchResources>()
.is_some_and(|all| all.batches.contains_key(self.id.as_str()))
{
log::warn!(
"sprite_batch[{}]: rebuilding pipeline for format/sample/descriptor change",
self.id
);
}
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("sprite_batch"),
source: wgpu::ShaderSource::Wgsl(include_str!("../shaders/sprite.wgsl").into()),
});
let corners = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("sprite_batch_corners"),
contents: bytemuck::cast_slice(CORNERS),
usage: wgpu::BufferUsages::VERTEX,
});
let camera = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("sprite_batch_camera"),
size: 64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let instances = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("sprite_batch_instances"),
size: (self.instances.len().max(1) * size_of::<BatchInstance>()) as u64,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let cam_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("sprite_batch_cam_bgl"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}],
});
let cam_bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("sprite_batch_cam_bg"),
layout: &cam_layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: camera.as_entire_binding(),
}],
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("sprite_batch_pl"),
bind_group_layouts: &[Some(&cam_layout), Some(&layout.tex_layout)],
immediate_size: 0,
});
let vertex_buffers = [
Some(wgpu::VertexBufferLayout {
array_stride: 8,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &[wgpu::VertexAttribute {
format: wgpu::VertexFormat::Float32x2,
offset: 0,
shader_location: 0,
}],
}),
Some(wgpu::VertexBufferLayout {
array_stride: size_of::<BatchInstance>() as u64,
step_mode: wgpu::VertexStepMode::Instance,
attributes: &[
wgpu::VertexAttribute {
format: wgpu::VertexFormat::Float32x4,
offset: 0,
shader_location: 2,
},
wgpu::VertexAttribute {
format: wgpu::VertexFormat::Float32x4,
offset: 16,
shader_location: 3,
},
wgpu::VertexAttribute {
format: wgpu::VertexFormat::Float32x2,
offset: 32,
shader_location: 4,
},
wgpu::VertexAttribute {
format: wgpu::VertexFormat::Float32x2,
offset: 40,
shader_location: 5,
},
wgpu::VertexAttribute {
format: wgpu::VertexFormat::Float32x4,
offset: 48,
shader_location: 6,
},
wgpu::VertexAttribute {
format: wgpu::VertexFormat::Float32,
offset: 64,
shader_location: 7,
},
wgpu::VertexAttribute {
format: wgpu::VertexFormat::Float32,
offset: 68,
shader_location: 8,
},
wgpu::VertexAttribute {
format: wgpu::VertexFormat::Uint32,
offset: 72,
shader_location: 9,
},
wgpu::VertexAttribute {
format: wgpu::VertexFormat::Float32,
offset: 76,
shader_location: 10,
},
],
}),
];
let mk_pipeline = |label: &'static str, blend: wgpu::BlendState| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(label),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_main"),
buffers: &vertex_buffers,
compilation_options: Default::default(),
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_main"),
targets: &[Some(wgpu::ColorTargetState {
format: screen.target_format,
blend: Some(blend),
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: Default::default(),
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth24PlusStencil8,
depth_write_enabled: Some(false),
depth_compare: Some(wgpu::CompareFunction::Always),
stencil: wgpu::StencilState {
front: wgpu::StencilFaceState {
compare: wgpu::CompareFunction::LessEqual,
..Default::default()
},
back: wgpu::StencilFaceState {
compare: wgpu::CompareFunction::LessEqual,
..Default::default()
},
..Default::default()
},
bias: wgpu::DepthBiasState::default(),
}),
multisample: wgpu::MultisampleState {
count: screen.sample_count,
mask: !0,
alpha_to_coverage_enabled: false,
},
multiview_mask: None,
cache: None,
})
};
let pipeline_alpha = mk_pipeline("sprite_batch_pipeline", wgpu::BlendState::ALPHA_BLENDING);
let pipeline_multiply = mk_pipeline(
"sprite_batch_pipeline_mul",
wgpu::BlendState {
color: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::Dst,
dst_factor: wgpu::BlendFactor::Zero,
operation: wgpu::BlendOperation::Add,
},
alpha: wgpu::BlendComponent::OVER,
},
);
let pipeline_additive = mk_pipeline(
"sprite_batch_pipeline_add",
wgpu::BlendState {
color: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::SrcAlpha,
dst_factor: wgpu::BlendFactor::One,
operation: wgpu::BlendOperation::Add,
},
alpha: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::One,
dst_factor: wgpu::BlendFactor::One,
operation: wgpu::BlendOperation::Add,
},
},
);
let entry = BatchEntry {
key,
pipeline_alpha,
pipeline_multiply,
pipeline_additive,
corners,
instances,
instance_cap: self.instances.len().max(1),
last_alpha: 0,
last_multiply_end: 0,
last_total: 0,
camera,
cam_bind,
atlas,
atlas_key,
};
resources
.get_mut::<BatchResources>()
.expect("batch resources initialized")
.batches
.insert(self.id.clone(), entry);
resources
.get_mut::<BatchResources>()
.expect("batch resources initialized")
.prune_atlases();
}
fn finish_prepare(
&self,
alpha: u32,
multiply_end: u32,
total: u32,
resources: &mut CallbackResources,
) {
if let Some(all) = resources.get_mut::<BatchResources>()
&& let Some(res) = all.batches.get_mut(self.id.as_str())
{
res.last_alpha = alpha;
res.last_multiply_end = multiply_end;
res.last_total = total;
}
}
fn blend_group(flags: u32) -> u32 {
match flags {
1 => 2, 2 => 1, _ => 0, }
}
fn sorted_instances(&self) -> (Vec<BatchInstance>, u32, u32) {
let mut v: Vec<(usize, BatchInstance)> =
self.instances.iter().copied().enumerate().collect();
v.sort_by(|(ia, a), (ib, b)| {
Self::blend_group(a.flags)
.cmp(&Self::blend_group(b.flags))
.then_with(|| a.z.total_cmp(&b.z).then_with(|| ia.cmp(ib)))
});
let v: Vec<BatchInstance> = v.into_iter().map(|(_, inst)| inst).collect();
let alpha = v
.iter()
.take_while(|i| Self::blend_group(i.flags) == 0)
.count() as u32;
let multiply_end = alpha
+ v[alpha as usize..]
.iter()
.take_while(|i| Self::blend_group(i.flags) == 1)
.count() as u32;
(v, alpha, multiply_end)
}
pub(crate) fn prepare_with_uploads(
&self,
device: &wgpu::Device,
queue: &wgpu::Queue,
_encoder: &mut wgpu::CommandEncoder,
screen: &ScreenDescriptor,
resources: &mut CallbackResources,
uploads: &[AtlasUpload],
) -> Vec<wgpu::CommandBuffer> {
self.ensure_resources(device, screen, resources);
let (sorted, alpha, multiply_end) = self.sorted_instances();
let total = sorted.len() as u32;
let Some(all) = resources.get_mut::<BatchResources>() else {
return Vec::new();
};
let Some(res) = all.batches.get_mut(self.id.as_str()) else {
return Vec::new();
};
if sorted.len() > res.instance_cap {
let new_cap = sorted.len().next_power_of_two().max(64);
res.instances = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("sprite_batch_instances"),
size: (new_cap * size_of::<BatchInstance>()) as u64,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
res.instance_cap = new_cap;
}
queue.write_buffer(
&res.camera,
0,
bytemuck::cast_slice(&[CameraUniform {
view_proj: self.camera,
}]),
);
if !sorted.is_empty() {
queue.write_buffer(&res.instances, 0, bytemuck::cast_slice(&sorted));
}
let atlas = res.atlas.clone();
let fresh_atlas = atlas
.applied_gen
.lock()
.map(|generation| *generation != Some(self.desc.uploads_gen))
.unwrap_or(true);
if fresh_atlas && uploads.is_empty() {
self.finish_prepare(alpha, multiply_end, total, resources);
return Vec::new();
}
let mut wrote_any = false;
for up in uploads {
if !fresh_atlas {
continue;
}
let expected = up.w as usize * up.h as usize * 4;
if up.page >= self.desc.layers
|| up.x + up.w > self.desc.layer_size
|| up.y + up.h > self.desc.layer_size
|| up.rgba.len() != expected
{
log::warn!(
"sprite_batch: dropping out-of-range upload page={} {}x{}+{}+{} ({} bytes)",
up.page,
up.w,
up.h,
up.x,
up.y,
up.rgba.len()
);
continue;
}
queue.write_texture(
wgpu::TexelCopyTextureInfo {
texture: &atlas.texture,
mip_level: 0,
origin: wgpu::Origin3d {
x: up.x,
y: up.y,
z: up.page,
},
aspect: wgpu::TextureAspect::All,
},
&up.rgba,
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(up.w * 4),
rows_per_image: Some(up.h),
},
wgpu::Extent3d {
width: up.w,
height: up.h,
depth_or_array_layers: 1,
},
);
wrote_any = true;
}
if fresh_atlas
&& wrote_any
&& let Ok(mut generation) = atlas.applied_gen.lock()
{
*generation = Some(self.desc.uploads_gen);
}
self.finish_prepare(alpha, multiply_end, total, resources);
Vec::new()
}
}
trait BatchRenderPass {
fn set_bind_group(&mut self, index: u32, bind_group: &wgpu::BindGroup, offsets: &[u32]);
fn set_vertex_buffer(&mut self, slot: u32, buffer: wgpu::BufferSlice<'_>);
fn set_pipeline(&mut self, pipeline: &wgpu::RenderPipeline);
fn draw(&mut self, vertices: std::ops::Range<u32>, instances: std::ops::Range<u32>);
}
impl BatchRenderPass for wgpu::RenderPass<'_> {
fn set_bind_group(&mut self, index: u32, bind_group: &wgpu::BindGroup, offsets: &[u32]) {
self.set_bind_group(index, bind_group, offsets);
}
fn set_vertex_buffer(&mut self, slot: u32, buffer: wgpu::BufferSlice<'_>) {
self.set_vertex_buffer(slot, buffer);
}
fn set_pipeline(&mut self, pipeline: &wgpu::RenderPipeline) {
self.set_pipeline(pipeline);
}
fn draw(&mut self, vertices: std::ops::Range<u32>, instances: std::ops::Range<u32>) {
self.draw(vertices, instances);
}
}
impl BatchRenderPass for CallbackRenderPass<'_, '_> {
fn set_bind_group(&mut self, index: u32, bind_group: &wgpu::BindGroup, offsets: &[u32]) {
self.set_bind_group(index, bind_group, offsets);
}
fn set_vertex_buffer(&mut self, slot: u32, buffer: wgpu::BufferSlice<'_>) {
self.set_vertex_buffer(slot, buffer);
}
fn set_pipeline(&mut self, pipeline: &wgpu::RenderPipeline) {
self.set_pipeline(pipeline);
}
fn draw(&mut self, vertices: std::ops::Range<u32>, instances: std::ops::Range<u32>) {
self.draw(vertices, instances);
}
}
fn draw_batch_with_pass<P: BatchRenderPass>(
id: &str,
rpass: &mut P,
resources: &CallbackResources,
) {
let Some(all) = resources.get::<BatchResources>() else {
return;
};
let Some(res) = all.batches.get(id) else {
return;
};
if res.last_total == 0 {
return;
}
rpass.set_bind_group(0, &res.cam_bind, &[]);
rpass.set_bind_group(1, &res.atlas.tex_bind, &[]);
rpass.set_vertex_buffer(0, res.corners.slice(..));
rpass.set_vertex_buffer(1, res.instances.slice(..));
if res.last_alpha > 0 {
rpass.set_pipeline(&res.pipeline_alpha);
rpass.draw(0..6, 0..res.last_alpha);
}
if res.last_multiply_end > res.last_alpha {
rpass.set_pipeline(&res.pipeline_multiply);
rpass.draw(0..6, res.last_alpha..res.last_multiply_end);
}
if res.last_total > res.last_multiply_end {
rpass.set_pipeline(&res.pipeline_additive);
rpass.draw(0..6, res.last_multiply_end..res.last_total);
}
}
pub fn draw_batch_with_id(
id: &str,
rpass: &mut wgpu::RenderPass<'_>,
resources: &CallbackResources,
) {
draw_batch_with_pass(id, rpass, resources);
}
pub(crate) fn draw_batch_with_id_callback(
id: &str,
rpass: &mut CallbackRenderPass<'_, '_>,
resources: &CallbackResources,
) {
draw_batch_with_pass(id, rpass, resources);
}
pub fn draw_batch(rpass: &mut wgpu::RenderPass<'_>, resources: &CallbackResources) {
draw_batch_with_id("sprite_batch.default", rpass, resources);
}
impl WgpuCallback for SpriteBatch {
fn resource_key(&self) -> Option<&str> {
Some("repame.sprite_batch.resources")
}
fn prepare(
&self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
screen: &ScreenDescriptor,
resources: &mut CallbackResources,
) -> Vec<wgpu::CommandBuffer> {
self.prepare_with_uploads(device, queue, encoder, screen, resources, &self.uploads)
}
fn paint(
&self,
_info: repose_core::PaintCallbackInfo,
rpass: &mut CallbackRenderPass<'_, '_>,
resources: &CallbackResources,
) {
draw_batch_with_id_callback(self.id.as_str(), rpass, resources);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn apply(rows: ([f32; 4], [f32; 4]), corner: [f32; 2]) -> [f32; 2] {
let (r0, r1) = rows;
[
r0[0] * corner[0] + r0[1] * corner[1] + r0[3],
r1[0] * corner[0] + r1[1] * corner[1] + r1[3],
]
}
#[test]
fn default_rows_match_canvas_top_left() {
let rows = instance_rows([400.0, 300.0], [64.0, 80.0], 0.0, [0.5, 0.5], false, false);
let tl = apply(rows, [-0.5, -0.5]);
let br = apply(rows, [0.5, 0.5]);
assert_eq!(tl, [368.0, 260.0]);
assert_eq!(br, [432.0, 340.0]);
}
#[test]
fn frame_uv_indexes_row_major() {
let (mn, mx) = frame_uv(4, 2, 5);
assert_eq!(mn, [0.25, 0.5]);
assert_eq!(mx, [0.5, 1.0]);
assert_eq!(frame_uv(4, 2, 0).0, [0.0, 0.0]);
assert_eq!(frame_uv(4, 2, 99), frame_uv(4, 2, 7));
assert_eq!(frame_uv(0, 0, 3), ([0.0, 0.0], [1.0, 1.0]));
}
#[test]
fn sprite_aabb_matches_unrotated_quad() {
let (mn, mx) = sprite_aabb([400.0, 300.0], [64.0, 80.0], 0.0, [0.5, 0.5], false, false);
assert_eq!(mn, [368.0, 260.0]);
assert_eq!(mx, [432.0, 340.0]);
let (mn, mx) = sprite_aabb(
[0.0, 0.0],
[64.0, 80.0],
std::f32::consts::FRAC_PI_2,
[0.5, 0.5],
false,
false,
);
assert!((mn[0] + 40.0).abs() < 1e-4 && (mn[1] + 32.0).abs() < 1e-4);
assert!((mx[0] - 40.0).abs() < 1e-4 && (mx[1] - 32.0).abs() < 1e-4);
}
#[test]
fn quarter_turn_rotates_footprint() {
let rows = instance_rows(
[0.0, 0.0],
[64.0, 80.0],
std::f32::consts::FRAC_PI_2,
[0.5, 0.5],
false,
false,
);
let p = apply(rows, [-0.5, -0.5]);
assert!(
(p[0] - 40.0).abs() < 1e-4 && (p[1] + 32.0).abs() < 1e-4,
"got {p:?}"
);
}
#[test]
fn top_left_anchor_pins_corner() {
let rows = instance_rows([100.0, 100.0], [64.0, 80.0], 0.0, [0.0, 0.0], false, false);
let tl = apply(rows, [-0.5, -0.5]);
assert_eq!(tl, [100.0, 100.0]);
}
#[test]
fn flip_x_mirrors_around_anchor() {
let plain = instance_rows([0.0, 0.0], [64.0, 8.0], 0.0, [0.5, 0.5], false, false);
let flipped = instance_rows([0.0, 0.0], [64.0, 8.0], 0.0, [0.5, 0.5], true, false);
let a = apply(plain, [-0.5, 0.0]);
let b = apply(flipped, [-0.5, 0.0]);
assert_eq!(a, [-32.0, 0.0]);
assert_eq!(b, [32.0, 0.0]);
}
#[test]
fn screen_camera_is_y_down() {
let cam = screen_camera([800.0, 600.0]);
let top_left = cam.project_point3(glam::Vec3::new(0.0, 0.0, 0.0));
let bottom_right = cam.project_point3(glam::Vec3::new(800.0, 600.0, 0.0));
let center = cam.project_point3(glam::Vec3::new(400.0, 300.0, 0.0));
assert!((top_left.x + 1.0).abs() < 1e-5 && (top_left.y - 1.0).abs() < 1e-5);
assert!((bottom_right.x - 1.0).abs() < 1e-5 && (bottom_right.y + 1.0).abs() < 1e-5);
assert!(center.x.abs() < 1e-5 && center.y.abs() < 1e-5);
}
#[test]
fn push_sprite_bridges_snapshot_fields() {
let mut batch = SpriteBatch::new(BatchDesc::default());
batch.push_sprite(&SpriteInstance {
center: glam::Vec2::new(10.0, 20.0),
size: glam::Vec2::new(4.0, 6.0),
uv_min: glam::Vec2::new(0.25, 0.5),
uv_max: glam::Vec2::new(0.5, 0.75),
color: [1.0, 0.5, 0.25, 0.8],
page: 3,
..Default::default()
});
assert_eq!(batch.len(), 1);
let inst = batch.instances[0];
assert_eq!(inst.uv_min, [0.25, 0.5]);
assert_eq!(inst.uv_max, [0.5, 0.75]);
assert_eq!(inst.tint, [1.0, 0.5, 0.25, 0.8]);
assert_eq!(inst.page, 3.0);
let p = apply((inst.row0, inst.row1), [-0.5, -0.5]);
assert_eq!(p, [8.0, 17.0]);
}
#[test]
fn z_and_blend_sort_into_ranges() {
use super::super::SpriteBlend;
let mut batch = SpriteBatch::with_id("test.sort", BatchDesc::default());
let quad = |z: f32, blend: SpriteBlend| SpriteInstance {
center: glam::Vec2::new(0.0, 0.0),
size: glam::Vec2::new(1.0, 1.0),
z,
blend,
..Default::default()
};
batch.push_sprite(&quad(5.0, SpriteBlend::Alpha));
batch.push_sprite(&quad(1.0, SpriteBlend::Additive));
batch.push_sprite(&quad(3.0, SpriteBlend::Multiply));
batch.push_sprite(&quad(2.0, SpriteBlend::Alpha));
batch.push_sprite(&quad(0.0, SpriteBlend::Multiply));
let (sorted, alpha, mul_end) = batch.sorted_instances();
assert_eq!((alpha, mul_end, sorted.len() as u32), (2, 4, 5));
assert_eq!([sorted[0].z, sorted[1].z], [2.0, 5.0]);
assert_eq!([sorted[2].z, sorted[3].z], [0.0, 3.0]);
assert_eq!(sorted[4].z, 1.0);
assert_eq!(sorted[4].flags, 1);
}
#[test]
fn same_z_keeps_push_order_across_pages() {
use super::super::SpriteBlend;
let mut batch = SpriteBatch::with_id("test.pages", BatchDesc::default());
let quad = |page: u32| SpriteInstance {
center: glam::Vec2::new(0.0, 0.0),
size: glam::Vec2::new(1.0, 1.0),
z: 0.0,
blend: SpriteBlend::Alpha,
page,
..Default::default()
};
batch.push_sprite(&quad(1));
batch.push_sprite(&quad(0));
let (sorted, _, _) = batch.sorted_instances();
assert_eq!([sorted[0].page, sorted[1].page], [1.0, 0.0]);
}
#[test]
fn white_upload_is_1x1_white() {
let up = AtlasUpload::white(2, 5, 7);
assert_eq!((up.page, up.x, up.y, up.w, up.h), (2, 5, 7, 1, 1));
assert_eq!(up.rgba, vec![255, 255, 255, 255]);
}
#[test]
fn offscreen_batch_draws_oriented_textured_quads() {
use repose_core::{Color, Rect, Scene, SceneNode};
use repose_render_wgpu::{Callback, offscreen::OffscreenRenderer};
let mut renderer = match OffscreenRenderer::new_blocking(256, 256, 1) {
Ok(r) => r,
Err(e) => {
eprintln!("SKIP offscreen batch test (no GPU): {e}");
return;
}
};
let page0 = vec![
255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 255, 255,
];
let page1 = [255, 0, 255, 255].repeat(4);
let mut batch = SpriteBatch::new(BatchDesc {
layer_size: 2,
layers: 2,
filter: TextureFilter::Nearest,
..Default::default()
});
batch.upload(AtlasUpload {
page: 0,
x: 0,
y: 0,
w: 2,
h: 2,
rgba: page0,
});
batch.upload(AtlasUpload {
page: 1,
x: 0,
y: 0,
w: 2,
h: 2,
rgba: page1,
});
batch.set_camera(screen_camera([256.0, 256.0]));
batch.push(
[128.0, 128.0],
[128.0, 128.0],
0.0,
[0.5, 0.5],
false,
false,
[0.0, 0.0],
[1.0, 1.0],
[1.0, 1.0, 1.0, 1.0],
0,
);
batch.push(
[224.0, 224.0],
[32.0, 32.0],
0.0,
[0.5, 0.5],
false,
false,
[0.0, 0.0],
[1.0, 1.0],
[1.0, 1.0, 1.0, 1.0],
1,
);
let scene = Scene {
clear_color: Color::from_rgba(0, 0, 0, 255),
nodes: vec![SceneNode::Callback {
rect: Rect {
x: 0.0,
y: 0.0,
w: 256.0,
h: 256.0,
},
payload: Callback::new(batch),
}],
};
let px = renderer
.render_rgba(&scene, Some([0.0, 0.0, 0.0, 1.0]))
.expect("offscreen render");
let at = |x: u32, y: u32| -> [u8; 4] {
let i = ((y * 256 + x) * 4) as usize;
[px[i], px[i + 1], px[i + 2], px[i + 3]]
};
assert_eq!(at(64, 64), [255, 0, 0, 255], "top-left red");
assert_eq!(at(191, 64), [0, 255, 0, 255], "top-right green");
assert_eq!(at(64, 191), [0, 0, 255, 255], "bottom-left blue");
assert_eq!(at(191, 191), [255, 255, 255, 255], "bottom-right white");
assert_eq!(at(224, 224), [255, 0, 255, 255], "page 1 magenta");
assert_eq!(at(8, 8), [0, 0, 0, 255], "background clear");
let mut flipped = SpriteBatch::new(BatchDesc {
layer_size: 2,
layers: 2,
filter: TextureFilter::Nearest,
..Default::default()
});
flipped.set_camera(screen_camera([256.0, 256.0]));
flipped.push(
[128.0, 128.0],
[128.0, 128.0],
0.0,
[0.5, 0.5],
true,
false,
[0.0, 0.0],
[1.0, 1.0],
[1.0, 1.0, 1.0, 1.0],
0,
);
let scene = Scene {
clear_color: Color::from_rgba(0, 0, 0, 255),
nodes: vec![SceneNode::Callback {
rect: Rect {
x: 0.0,
y: 0.0,
w: 256.0,
h: 256.0,
},
payload: Callback::new(flipped),
}],
};
let px = renderer
.render_rgba(&scene, Some([0.0, 0.0, 0.0, 1.0]))
.expect("offscreen render");
let at = |x: u32, y: u32| -> [u8; 4] {
let i = ((y * 256 + x) * 4) as usize;
[px[i], px[i + 1], px[i + 2], px[i + 3]]
};
assert_eq!(at(64, 64), [0, 255, 0, 255], "flipped top-left green");
assert_eq!(at(191, 191), [0, 0, 255, 255], "flipped bottom-right blue");
}
}