diff --git a/examples/invaders/src/main.rs b/examples/invaders/src/main.rs index 57835a1d..658ca78a 100644 --- a/examples/invaders/src/main.rs +++ b/examples/invaders/src/main.rs @@ -122,13 +122,16 @@ fn main() -> Result<(), Error> { Arc::new(window) }; - let pixels = { + let mut pixels = { let window_size = window.inner_size(); let surface_texture = SurfaceTexture::new(window_size.width, window_size.height, Arc::clone(&window)); Pixels::new(WIDTH as u32, HEIGHT as u32, surface_texture)? }; + // Use the fill scaling mode which supports non-integer scaling. + pixels.set_scaling_mode(pixels::ScalingMode::Fill); + let game = Game::new(pixels, debug); let res = game_loop( diff --git a/shaders/scale.wgsl b/shaders/scale.wgsl index d977f201..9dc121e5 100644 --- a/shaders/scale.wgsl +++ b/shaders/scale.wgsl @@ -7,6 +7,7 @@ struct VertexOutput { struct Locals { transform: mat4x4, + input_size: vec4 } @group(0) @binding(2) var r_locals: Locals; diff --git a/shaders/scale_fill.wgsl b/shaders/scale_fill.wgsl new file mode 100644 index 00000000..a51e0f25 --- /dev/null +++ b/shaders/scale_fill.wgsl @@ -0,0 +1,40 @@ +// Vertex shader bindings + +struct VertexOutput { + @location(0) tex_coord: vec2, + @builtin(position) position: vec4, +} + +struct Locals { + transform: mat4x4, + input_size: vec4 +} +@group(0) @binding(2) var r_locals: Locals; + +@vertex +fn vs_main( + @location(0) position: vec2, +) -> VertexOutput { + var out: VertexOutput; + // Output tex coord in texel coordinates (0..width, 0..height) + out.tex_coord = fma(position, vec2(0.5, -0.5), vec2(0.5, 0.5)) * r_locals.input_size.xy; + out.position = r_locals.transform * vec4(position, 0.0, 1.0); + return out; +} + +// Fragment shader bindings + +@group(0) @binding(0) var r_tex_color: texture_2d; +@group(0) @binding(1) var r_tex_sampler: sampler; + +@fragment +fn fs_main(@location(0) tex_coord: vec2) -> @location(0) vec4 { + let half = vec2(0.5); + let one = vec2(1.0); + let zero = vec2(0.0); + let texels_per_pixel = vec2(dpdx(tex_coord.x), dpdy(tex_coord.y)); + let tex_coord_fract = fract(tex_coord); + let tex_coord_x = clamp(tex_coord_fract / texels_per_pixel, zero, half) + clamp((tex_coord_fract - one) / texels_per_pixel + half, zero, half); + let tex_coord_final = (floor(tex_coord) + tex_coord_x) * r_locals.input_size.zw; + return textureSample(r_tex_color, r_tex_sampler, tex_coord_final); +} diff --git a/src/builder.rs b/src/builder.rs index 8ceaf4cd..998830b7 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -1,5 +1,5 @@ use crate::renderers::{ScalingMatrix, ScalingRenderer}; -use crate::{Error, Pixels, PixelsContext, SurfaceSize, SurfaceTexture, TextureError}; +use crate::{Error, Pixels, PixelsContext, ScalingMode, SurfaceSize, SurfaceTexture, TextureError}; /// A builder to help create customized pixel buffers. pub struct PixelsBuilder<'req, 'dev, 'win, W: wgpu::WindowHandle + 'win> { @@ -309,6 +309,7 @@ impl<'req, 'dev, 'win, W: wgpu::WindowHandle + 'win> PixelsBuilder<'req, 'dev, ' let surface_size = self.surface_texture.size; let clear_color = self.clear_color; let blend_state = self.blend_state; + let scaling_mode = ScalingMode::PixelPerfect; let (scaling_matrix_inverse, texture_extent, texture, scaling_renderer, pixels_buffer_size) = create_backing_texture( &device, @@ -322,6 +323,7 @@ impl<'req, 'dev, 'win, W: wgpu::WindowHandle + 'win> PixelsBuilder<'req, 'dev, ' // Clear color and blending values clear_color, blend_state, + scaling_mode, )?; // Create the pixel buffer @@ -432,6 +434,7 @@ pub(crate) fn create_backing_texture( render_texture_format: wgpu::TextureFormat, clear_color: wgpu::Color, blend_state: wgpu::BlendState, + scaling_mode: ScalingMode, ) -> Result< ( ultraviolet::Mat4, @@ -447,6 +450,7 @@ pub(crate) fn create_backing_texture( let scaling_matrix_inverse = ScalingMatrix::new( (width as f32, height as f32), (surface_size.width as f32, surface_size.height as f32), + scaling_mode, ) .transform .inversed(); @@ -477,6 +481,7 @@ pub(crate) fn create_backing_texture( render_texture_format, clear_color, blend_state, + scaling_mode, ); let texture_format_size = texture_format_size(backing_texture_format); diff --git a/src/lib.rs b/src/lib.rs index 88b6dd74..04542170 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -55,6 +55,16 @@ struct SurfaceSize { height: u32, } +/// The scaling mode controls the scaling behavior of [`renderers::ScalingRenderer`]. +#[derive(Debug, Copy, Clone)] +pub enum ScalingMode { + /// The buffer is scaled up, if needed, to the nearest integer multiple of the buffer size. + PixelPerfect, + /// Fill the screen while preserving aspect ratio. The renderer effectively scales the buffer + /// to the nearest integer multiple first, then linearly interpolates to fit. + Fill, +} + /// Provides the internal state for custom shaders. /// /// A reference to this struct is given to the `render_function` closure when using @@ -295,6 +305,23 @@ impl<'win> Pixels<'win> { self.context.scaling_renderer.clear_color = color; } + /// Set the scaling mode. + /// + /// Controls how the pixel buffer is scaled to the screen. + /// + /// ```no_run + /// # use pixels::{Pixels, ScalingMode}; + /// # let window = pixels_mocks::Window; + /// # let surface_texture = pixels::SurfaceTexture::new(1920, 1080, &window); + /// let mut pixels = Pixels::new(640, 480, surface_texture)?; + /// // Scale the buffer up to fill the screen while preserving aspect ratio. + /// pixels.set_scaling_mode(ScalingMode::Fill); + /// # Ok::<(), pixels::Error>(()) + /// ``` + pub fn set_scaling_mode(&mut self, scaling_mode: ScalingMode) { + self.context.scaling_renderer.scaling_mode = scaling_mode; + } + /// Returns a reference of the `wgpu` adapter used by the crate. /// /// The adapter can be used to retrieve runtime information about the host system @@ -343,6 +370,7 @@ impl<'win> Pixels<'win> { self.render_texture_format, self.context.scaling_renderer.clear_color, self.blend_state, + self.context.scaling_renderer.scaling_mode, )?; self.scaling_matrix_inverse = scaling_matrix_inverse; @@ -387,6 +415,7 @@ impl<'win> Pixels<'win> { self.context.texture_extent.height as f32, ), (width as f32, height as f32), + self.context.scaling_renderer.scaling_mode, ) .transform .inversed(); diff --git a/src/renderers.rs b/src/renderers.rs index 00df39a1..748f4202 100644 --- a/src/renderers.rs +++ b/src/renderers.rs @@ -1,4 +1,4 @@ -use crate::SurfaceSize; +use crate::{ScalingMode, SurfaceSize}; use ultraviolet::Mat4; use wgpu::util::DeviceExt; @@ -7,15 +7,19 @@ use wgpu::util::DeviceExt; pub struct ScalingRenderer { vertex_buffer: wgpu::Buffer, uniform_buffer: wgpu::Buffer, - bind_group: wgpu::BindGroup, + bind_group_nearest: wgpu::BindGroup, + bind_group_linear: wgpu::BindGroup, render_pipeline: wgpu::RenderPipeline, + render_pipeline_fill: wgpu::RenderPipeline, pub(crate) clear_color: wgpu::Color, width: f32, height: f32, clip_rect: (u32, u32, u32, u32), + pub(crate) scaling_mode: ScalingMode, } impl ScalingRenderer { + #[allow(clippy::too_many_arguments)] pub(crate) fn new( device: &wgpu::Device, texture_view: &wgpu::TextureView, @@ -24,13 +28,17 @@ impl ScalingRenderer { render_texture_format: wgpu::TextureFormat, clear_color: wgpu::Color, blend_state: wgpu::BlendState, + scaling_mode: ScalingMode, ) -> Self { let shader = wgpu::include_wgsl!("../shaders/scale.wgsl"); let module = device.create_shader_module(shader); + let shader_fill = wgpu::include_wgsl!("../shaders/scale_fill.wgsl"); + let module_fill = device.create_shader_module(shader_fill); + // Create a texture sampler with nearest neighbor - let sampler = device.create_sampler(&wgpu::SamplerDescriptor { - label: Some("pixels_scaling_renderer_sampler"), + let sampler_nearest = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("pixels_scaling_renderer_sampler_nearest"), address_mode_u: wgpu::AddressMode::ClampToEdge, address_mode_v: wgpu::AddressMode::ClampToEdge, address_mode_w: wgpu::AddressMode::ClampToEdge, @@ -44,6 +52,21 @@ impl ScalingRenderer { border_color: None, }); + let sampler_linear = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("pixels_scaling_renderer_sampler_linear"), + address_mode_u: wgpu::AddressMode::ClampToEdge, + address_mode_v: wgpu::AddressMode::ClampToEdge, + address_mode_w: wgpu::AddressMode::ClampToEdge, + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + mipmap_filter: wgpu::FilterMode::Linear, + lod_min_clamp: 0.0, + lod_max_clamp: 1.0, + compare: None, + anisotropy_clamp: 1, + border_color: None, + }); + // Create vertex buffer; array-of-array of position and texture coordinates let vertex_data: [[f32; 2]; 3] = [ // One full-screen triangle @@ -72,11 +95,11 @@ impl ScalingRenderer { let matrix = ScalingMatrix::new( (texture_size.width as f32, texture_size.height as f32), (surface_size.width as f32, surface_size.height as f32), + scaling_mode, ); - let transform_bytes = matrix.as_bytes(); let uniform_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("pixels_scaling_renderer_matrix_uniform_buffer"), - contents: transform_bytes, + contents: matrix.uniform_buffer.as_slice(), usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, }); @@ -102,18 +125,36 @@ impl ScalingRenderer { }, wgpu::BindGroupLayoutEntry { binding: 2, - visibility: wgpu::ShaderStages::VERTEX, + visibility: wgpu::ShaderStages::VERTEX_FRAGMENT, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, - min_binding_size: wgpu::BufferSize::new(transform_bytes.len() as u64), + min_binding_size: wgpu::BufferSize::new(matrix.uniform_buffer.len() as u64), }, count: None, }, ], }); - let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("pixels_scaling_renderer_bind_group"), + let bind_group_nearest = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("pixels_scaling_renderer_bind_group_nearest"), + layout: &bind_group_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(texture_view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&sampler_nearest), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: uniform_buffer.as_entire_binding(), + }, + ], + }); + let bind_group_linear = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("pixels_scaling_renderer_bind_group_linear"), layout: &bind_group_layout, entries: &[ wgpu::BindGroupEntry { @@ -122,7 +163,7 @@ impl ScalingRenderer { }, wgpu::BindGroupEntry { binding: 1, - resource: wgpu::BindingResource::Sampler(&sampler), + resource: wgpu::BindingResource::Sampler(&sampler_linear), }, wgpu::BindGroupEntry { binding: 2, @@ -143,7 +184,7 @@ impl ScalingRenderer { vertex: wgpu::VertexState { module: &module, entry_point: "vs_main", - buffers: &[vertex_buffer_layout], + buffers: &[vertex_buffer_layout.clone()], }, primitive: wgpu::PrimitiveState::default(), depth_stencil: None, @@ -159,6 +200,28 @@ impl ScalingRenderer { }), multiview: None, }); + let render_pipeline_fill = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("pixels_scaling_renderer_pipeline_fill"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &module_fill, + entry_point: "vs_main", + buffers: &[vertex_buffer_layout], + }, + primitive: wgpu::PrimitiveState::default(), + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + fragment: Some(wgpu::FragmentState { + module: &module_fill, + entry_point: "fs_main", + targets: &[Some(wgpu::ColorTargetState { + format: render_texture_format, + blend: Some(blend_state), + write_mask: wgpu::ColorWrites::ALL, + })], + }), + multiview: None, + }); // Create clipping rectangle let clip_rect = matrix.clip_rect(); @@ -166,12 +229,15 @@ impl ScalingRenderer { Self { vertex_buffer, uniform_buffer, - bind_group, + bind_group_nearest, + bind_group_linear, render_pipeline, + render_pipeline_fill, clear_color, width: texture_size.width as f32, height: texture_size.height as f32, clip_rect, + scaling_mode, } } @@ -191,8 +257,16 @@ impl ScalingRenderer { timestamp_writes: None, occlusion_query_set: None, }); - rpass.set_pipeline(&self.render_pipeline); - rpass.set_bind_group(0, &self.bind_group, &[]); + let pipeline = match self.scaling_mode { + ScalingMode::PixelPerfect => &self.render_pipeline, + ScalingMode::Fill => &self.render_pipeline_fill, + }; + rpass.set_pipeline(pipeline); + let bind_group = match self.scaling_mode { + ScalingMode::PixelPerfect => &self.bind_group_nearest, + ScalingMode::Fill => &self.bind_group_linear, + }; + rpass.set_bind_group(0, bind_group, &[]); rpass.set_vertex_buffer(0, self.vertex_buffer.slice(..)); rpass.set_scissor_rect( self.clip_rect.0, @@ -211,9 +285,12 @@ impl ScalingRenderer { } pub(crate) fn resize(&mut self, queue: &wgpu::Queue, width: u32, height: u32) { - let matrix = ScalingMatrix::new((self.width, self.height), (width as f32, height as f32)); - let transform_bytes = matrix.as_bytes(); - queue.write_buffer(&self.uniform_buffer, 0, transform_bytes); + let matrix = ScalingMatrix::new( + (self.width, self.height), + (width as f32, height as f32), + self.scaling_mode, + ); + queue.write_buffer(&self.uniform_buffer, 0, &matrix.uniform_buffer); self.clip_rect = matrix.clip_rect(); } @@ -223,23 +300,36 @@ impl ScalingRenderer { pub(crate) struct ScalingMatrix { pub(crate) transform: Mat4, clip_rect: (u32, u32, u32, u32), + uniform_buffer: Vec, } impl ScalingMatrix { // texture_size is the dimensions of the drawing texture // screen_size is the dimensions of the surface being drawn to - pub(crate) fn new(texture_size: (f32, f32), screen_size: (f32, f32)) -> Self { + pub(crate) fn new( + texture_size: (f32, f32), + screen_size: (f32, f32), + scaling_mode: ScalingMode, + ) -> Self { let (texture_width, texture_height) = texture_size; let (screen_width, screen_height) = screen_size; - let width_ratio = (screen_width / texture_width).max(1.0); - let height_ratio = (screen_height / texture_height).max(1.0); - - // Get smallest scale size - let scale = width_ratio.clamp(1.0, height_ratio).floor(); - - let scaled_width = texture_width * scale; - let scaled_height = texture_height * scale; + let (scaled_width, scaled_height) = match scaling_mode { + ScalingMode::PixelPerfect => { + // Scale up to nearest integer multiple of screen size + let width_ratio = (screen_width / texture_width).max(1.0); + let height_ratio = (screen_height / texture_height).max(1.0); + let scale = width_ratio.min(height_ratio).floor().max(1.0); + (texture_width * scale, texture_height * scale) + } + ScalingMode::Fill => { + // Scale up or down while preserving aspect ratio + let width_ratio = screen_width / texture_width; + let height_ratio = screen_height / texture_height; + let scale = width_ratio.min(height_ratio); + (texture_width * scale, texture_height * scale) + } + }; // Create a transformation matrix let sw = scaled_width / screen_width; @@ -264,16 +354,23 @@ impl ScalingMatrix { (x, y, scaled_width as u32, scaled_height as u32) }; + let mat = Mat4::from(transform); + + // Compute the constant buffer + let mut uniform_buffer = Vec::new(); + uniform_buffer.extend_from_slice(mat.as_byte_slice()); + uniform_buffer.extend_from_slice(&texture_width.to_le_bytes()); + uniform_buffer.extend_from_slice(&texture_height.to_le_bytes()); + uniform_buffer.extend_from_slice(&(1.0 / texture_width).to_le_bytes()); + uniform_buffer.extend_from_slice(&(1.0 / texture_height).to_le_bytes()); + Self { - transform: Mat4::from(transform), + transform: mat, clip_rect, + uniform_buffer, } } - fn as_bytes(&self) -> &[u8] { - self.transform.as_byte_slice() - } - pub(crate) fn clip_rect(&self) -> (u32, u32, u32, u32) { self.clip_rect }