From 00f774aa9f0e3be92466e5e8e3dd0de8c8562cc4 Mon Sep 17 00:00:00 2001 From: Jay Oster Date: Tue, 8 Feb 2022 01:45:02 -0800 Subject: [PATCH] Add fill-window example --- README.md | 1 + examples/fill-window/Cargo.toml | 19 ++ examples/fill-window/README.md | 20 ++ examples/fill-window/shaders/fill.wgsl | 31 +++ examples/fill-window/src/main.rs | 143 ++++++++++++ examples/fill-window/src/renderers.rs | 309 +++++++++++++++++++++++++ img/fill-window.png | Bin 0 -> 13829 bytes 7 files changed, 523 insertions(+) create mode 100644 examples/fill-window/Cargo.toml create mode 100644 examples/fill-window/README.md create mode 100644 examples/fill-window/shaders/fill.wgsl create mode 100644 examples/fill-window/src/main.rs create mode 100644 examples/fill-window/src/renderers.rs create mode 100644 img/fill-window.png 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 0000000000000000000000000000000000000000..1704ddf1fa865dec907bc45cf0e5b32bce78c0d1 GIT binary patch literal 13829 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 E13zI|ivR!s literal 0 HcmV?d00001