diff --git a/README.md b/README.md index 629aef64..8c83f996 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ The Minimum Supported Rust Version for `pixels` will always be made available in - [Conway's Game of Life](./examples/conway) - [Custom Shader](./examples/custom-shader) +- [Fill arbitrary window sizes while maintaining the highest possible quality](./examples/fill-window) - [Dear ImGui example with `winit`](./examples/imgui-winit) - [Egui example with `winit`](./examples/minimal-egui) - [Minimal example for WebGL2](./examples/minimal-web) diff --git a/examples/fill-window/Cargo.toml b/examples/fill-window/Cargo.toml new file mode 100644 index 00000000..a11ed5c6 --- /dev/null +++ b/examples/fill-window/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "fill-window" +version = "0.1.0" +authors = ["Jay Oster "] +edition = "2021" +publish = false + +[features] +optimize = ["log/release_max_level_warn"] +default = ["optimize"] + +[dependencies] +bytemuck = "1.7" +env_logger = "0.9" +log = "0.4" +pixels = { path = "../.." } +ultraviolet = "0.8" +winit = "0.26" +winit_input_helper = "0.11" diff --git a/examples/fill-window/README.md b/examples/fill-window/README.md new file mode 100644 index 00000000..7c6c7c3e --- /dev/null +++ b/examples/fill-window/README.md @@ -0,0 +1,20 @@ +# Window-filling Example + +![Custom Shader Example](../../img/fill-window.png) + +## Running + +```bash +cargo run --release --package fill-window +``` + +## About + +This example is based on `minimal-winit` and `custom-shader`. It adds a custom renderer that completely fills the screen while maintaining high quality. + +Filling the screen necessarily creates artifacts (aliasing) due to a mismatch between the number of pixels in the pixel buffer and the number of pixels on the screen. The custom renderer provided here counters this aliasing issue with a two-pass approach: + +1. First the pixel buffer is scaled with the default scaling renderer, which keeps sharp pixel edges by only scaling to integer ratios with nearest neighbor texture filtering. +2. Then the custom renderer scales that result to the smallest non-integer multiple that will fill the screen without clipping, using bilinear texture filtering. + +This approach maintains the aspect ratio in the second pass by adding black "letterbox" or "pillarbox" borders as necessary. The two-pass method completely avoids pixel shimmering with single-pass nearest neighbor filtering, and also avoids blurring with single-pass bilinear filtering. The result has decent quality even when scaled up 100x. diff --git a/examples/fill-window/shaders/fill.wgsl b/examples/fill-window/shaders/fill.wgsl new file mode 100644 index 00000000..166b5dfd --- /dev/null +++ b/examples/fill-window/shaders/fill.wgsl @@ -0,0 +1,31 @@ +// Vertex shader bindings + +struct VertexOutput { + [[location(0)]] tex_coord: vec2; + [[builtin(position)]] position: vec4; +}; + +struct Locals { + transform: mat4x4; +}; +[[group(0), binding(2)]] var r_locals: Locals; + +[[stage(vertex)]] +fn vs_main( + [[location(0)]] position: vec2, +) -> VertexOutput { + var out: VertexOutput; + out.tex_coord = fma(position, vec2(0.5, -0.5), vec2(0.5, 0.5)); + 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; + +[[stage(fragment)]] +fn fs_main([[location(0)]] tex_coord: vec2) -> [[location(0)]] vec4 { + return textureSample(r_tex_color, r_tex_sampler, tex_coord); +} diff --git a/examples/fill-window/src/main.rs b/examples/fill-window/src/main.rs new file mode 100644 index 00000000..5e641f7f --- /dev/null +++ b/examples/fill-window/src/main.rs @@ -0,0 +1,143 @@ +#![deny(clippy::all)] +#![forbid(unsafe_code)] + +use crate::renderers::FillRenderer; +use log::error; +use pixels::{Error, Pixels, SurfaceTexture}; +use winit::dpi::LogicalSize; +use winit::event::{Event, VirtualKeyCode}; +use winit::event_loop::{ControlFlow, EventLoop}; +use winit::window::WindowBuilder; +use winit_input_helper::WinitInputHelper; + +mod renderers; + +const WIDTH: u32 = 320; +const HEIGHT: u32 = 240; +const SCREEN_WIDTH: u32 = 1920; +const SCREEN_HEIGHT: u32 = 1080; +const BOX_SIZE: i16 = 64; + +/// Representation of the application state. In this example, a box will bounce around the screen. +struct World { + box_x: i16, + box_y: i16, + velocity_x: i16, + velocity_y: i16, +} + +fn main() -> Result<(), Error> { + env_logger::init(); + let event_loop = EventLoop::new(); + let mut input = WinitInputHelper::new(); + let window = { + let size = LogicalSize::new(SCREEN_WIDTH as f64, SCREEN_HEIGHT as f64); + WindowBuilder::new() + .with_title("Fill Window") + .with_inner_size(size) + .with_min_inner_size(size) + .build(&event_loop) + .unwrap() + }; + + let mut pixels = { + let window_size = window.inner_size(); + let surface_texture = SurfaceTexture::new(window_size.width, window_size.height, &window); + Pixels::new(WIDTH, HEIGHT, surface_texture)? + }; + let mut world = World::new(); + let mut fill_renderer = FillRenderer::new(&pixels, WIDTH, HEIGHT, SCREEN_WIDTH, SCREEN_HEIGHT); + + event_loop.run(move |event, _, control_flow| { + // Draw the current frame + if let Event::RedrawRequested(_) = event { + world.draw(pixels.get_frame()); + + let render_result = pixels.render_with(|encoder, render_target, context| { + let fill_texture = fill_renderer.get_texture_view(); + context.scaling_renderer.render(encoder, fill_texture); + + fill_renderer.render(encoder, render_target); + + Ok(()) + }); + + if render_result + .map_err(|e| error!("pixels.render_with() failed: {}", e)) + .is_err() + { + *control_flow = ControlFlow::Exit; + return; + } + } + + // Handle input events + if input.update(&event) { + // Close events + if input.key_pressed(VirtualKeyCode::Escape) || input.quit() { + *control_flow = ControlFlow::Exit; + return; + } + + // Resize the window + if let Some(size) = input.window_resized() { + pixels.resize_surface(size.width, size.height); + + let clip_rect = pixels.context().scaling_renderer.clip_rect(); + fill_renderer.resize(&pixels, clip_rect.2, clip_rect.3, size.width, size.height); + } + + // Update internal state and request a redraw + world.update(); + window.request_redraw(); + } + }); +} + +impl World { + /// Create a new `World` instance that can draw a moving box. + fn new() -> Self { + Self { + box_x: 24, + box_y: 16, + velocity_x: 1, + velocity_y: 1, + } + } + + /// Update the `World` internal state; bounce the box around the screen. + fn update(&mut self) { + if self.box_x <= 0 || self.box_x + BOX_SIZE > WIDTH as i16 { + self.velocity_x *= -1; + } + if self.box_y <= 0 || self.box_y + BOX_SIZE > HEIGHT as i16 { + self.velocity_y *= -1; + } + + self.box_x += self.velocity_x; + self.box_y += self.velocity_y; + } + + /// Draw the `World` state to the frame buffer. + /// + /// Assumes the default texture format: [`pixels::wgpu::TextureFormat::Rgba8UnormSrgb`] + fn draw(&self, frame: &mut [u8]) { + for (i, pixel) in frame.chunks_exact_mut(4).enumerate() { + let x = (i % WIDTH as usize) as i16; + let y = (i / WIDTH as usize) as i16; + + let inside_the_box = x >= self.box_x + && x < self.box_x + BOX_SIZE + && y >= self.box_y + && y < self.box_y + BOX_SIZE; + + let rgba = if inside_the_box { + [0x5e, 0x48, 0xe8, 0xff] + } else { + [0x48, 0xb2, 0xe8, 0xff] + }; + + pixel.copy_from_slice(&rgba); + } + } +} diff --git a/examples/fill-window/src/renderers.rs b/examples/fill-window/src/renderers.rs new file mode 100644 index 00000000..f0b8a317 --- /dev/null +++ b/examples/fill-window/src/renderers.rs @@ -0,0 +1,309 @@ +use pixels::wgpu::{self, util::DeviceExt}; +use ultraviolet::Mat4; + +pub(crate) struct FillRenderer { + texture_view: wgpu::TextureView, + sampler: wgpu::Sampler, + bind_group_layout: wgpu::BindGroupLayout, + bind_group: wgpu::BindGroup, + render_pipeline: wgpu::RenderPipeline, + uniform_buffer: wgpu::Buffer, + vertex_buffer: wgpu::Buffer, +} + +impl FillRenderer { + pub(crate) fn new( + pixels: &pixels::Pixels, + texture_width: u32, + texture_height: u32, + screen_width: u32, + screen_height: u32, + ) -> Self { + let device = pixels.device(); + let shader = wgpu::include_wgsl!("../shaders/fill.wgsl"); + let module = device.create_shader_module(&shader); + + // Create a texture view that will be used as input + // This will be used as the render target for the default scaling renderer + let texture_view = create_texture_view(pixels, screen_width, screen_height); + + // Create a texture sampler with bilinear filtering + let sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("FillRenderer sampler"), + 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: None, + border_color: None, + }); + + // Create vertex buffer; array-of-array of position and texture coordinates + let vertex_data: [[f32; 2]; 3] = [ + // One full-screen triangle + // See: https://github.com/parasyte/pixels/issues/180 + [-1.0, -1.0], + [3.0, -1.0], + [-1.0, 3.0], + ]; + let vertex_data_slice = bytemuck::cast_slice(&vertex_data); + let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("FillRenderer vertex buffer"), + contents: vertex_data_slice, + usage: wgpu::BufferUsages::VERTEX, + }); + let vertex_buffer_layout = wgpu::VertexBufferLayout { + array_stride: (vertex_data_slice.len() / vertex_data.len()) as wgpu::BufferAddress, + step_mode: wgpu::VertexStepMode::Vertex, + attributes: &[wgpu::VertexAttribute { + format: wgpu::VertexFormat::Float32x2, + offset: 0, + shader_location: 0, + }], + }; + + // Create uniform buffer + let matrix = ScalingMatrix::new( + (texture_width as f32, texture_height as f32), + (screen_width as f32, screen_height as f32), + ); + let transform_bytes = matrix.as_bytes(); + let uniform_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("FillRenderer Matrix Uniform Buffer"), + contents: transform_bytes, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + }); + + // Create bind group + let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: None, + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + multisampled: false, + view_dimension: wgpu::TextureViewDimension::D2, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::VERTEX, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, // TODO: More efficient to specify this + }, + count: None, + }, + ], + }); + let bind_group = create_bind_group( + device, + &bind_group_layout, + &texture_view, + &sampler, + &uniform_buffer, + ); + + // Create pipeline + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("FillRenderer pipeline layout"), + bind_group_layouts: &[&bind_group_layout], + push_constant_ranges: &[], + }); + let render_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("FillRenderer pipeline"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &module, + 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, + entry_point: "fs_main", + targets: &[wgpu::ColorTargetState { + format: pixels.render_texture_format(), + blend: Some(wgpu::BlendState { + color: wgpu::BlendComponent::REPLACE, + alpha: wgpu::BlendComponent::REPLACE, + }), + write_mask: wgpu::ColorWrites::ALL, + }], + }), + multiview: None, + }); + + Self { + texture_view, + sampler, + bind_group_layout, + bind_group, + render_pipeline, + uniform_buffer, + vertex_buffer, + } + } + + pub(crate) fn get_texture_view(&self) -> &wgpu::TextureView { + &self.texture_view + } + + pub(crate) fn resize( + &mut self, + pixels: &pixels::Pixels, + texture_width: u32, + texture_height: u32, + screen_width: u32, + screen_height: u32, + ) { + self.texture_view = create_texture_view(pixels, screen_width, screen_height); + self.bind_group = create_bind_group( + pixels.device(), + &self.bind_group_layout, + &self.texture_view, + &self.sampler, + &self.uniform_buffer, + ); + + let matrix = ScalingMatrix::new( + (texture_width as f32, texture_height as f32), + (screen_width as f32, screen_height as f32), + ); + let transform_bytes = matrix.as_bytes(); + pixels + .queue() + .write_buffer(&self.uniform_buffer, 0, transform_bytes); + } + + pub(crate) fn render( + &self, + encoder: &mut wgpu::CommandEncoder, + render_target: &wgpu::TextureView, + ) { + let mut rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("FillRenderer render pass"), + color_attachments: &[wgpu::RenderPassColorAttachment { + view: render_target, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), + store: true, + }, + }], + depth_stencil_attachment: None, + }); + rpass.set_pipeline(&self.render_pipeline); + rpass.set_bind_group(0, &self.bind_group, &[]); + rpass.set_vertex_buffer(0, self.vertex_buffer.slice(..)); + rpass.draw(0..3, 0..1); + } +} + +fn create_texture_view(pixels: &pixels::Pixels, width: u32, height: u32) -> wgpu::TextureView { + let device = pixels.device(); + let texture_descriptor = wgpu::TextureDescriptor { + label: None, + size: pixels::wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: pixels.render_texture_format(), + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::RENDER_ATTACHMENT, + }; + + device + .create_texture(&texture_descriptor) + .create_view(&wgpu::TextureViewDescriptor::default()) +} + +fn create_bind_group( + device: &wgpu::Device, + bind_group_layout: &wgpu::BindGroupLayout, + texture_view: &wgpu::TextureView, + sampler: &wgpu::Sampler, + time_buffer: &wgpu::Buffer, +) -> pixels::wgpu::BindGroup { + device.create_bind_group(&wgpu::BindGroupDescriptor { + label: None, + layout: bind_group_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(texture_view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(sampler), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: time_buffer.as_entire_binding(), + }, + ], + }) +} + +#[derive(Debug)] +struct ScalingMatrix { + transform: Mat4, +} + +impl ScalingMatrix { + // texture_size is the dimensions of the drawing texture + // screen_size is the dimensions of the surface being drawn to + fn new(texture_size: (f32, f32), screen_size: (f32, f32)) -> Self { + let (texture_width, texture_height) = texture_size; + let (screen_width, screen_height) = screen_size; + + // Get smallest scale size + let scale = (screen_width / texture_width) + .min(screen_height / texture_height) + .max(1.0); + + let scaled_width = texture_width * scale; + let scaled_height = texture_height * scale; + + // Create a transformation matrix + let sw = scaled_width / texture_width; + let sh = scaled_height / texture_height; + let tx = (texture_width / 2.0).fract() / texture_width; + let ty = (texture_height / 2.0).fract() / texture_height; + #[rustfmt::skip] + let transform: [f32; 16] = [ + sw, 0.0, 0.0, 0.0, + 0.0, sh, 0.0, 0.0, + 0.0, 0.0, 1.0, 0.0, + tx, ty, 0.0, 1.0, + ]; + + Self { + transform: Mat4::from(transform), + } + } + + fn as_bytes(&self) -> &[u8] { + self.transform.as_byte_slice() + } +} diff --git a/img/fill-window.png b/img/fill-window.png new file mode 100644 index 00000000..1704ddf1 Binary files /dev/null and b/img/fill-window.png differ