diff --git a/README.md b/README.md index 6ba0b809..1f6ef0c9 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ The Minimum Supported Rust Version for `pixels` will always be made available in - DirectX 11, WebGL2, and WebGPU support are a work in progress. - Use your own custom shaders for special effects. - Hardware accelerated scaling on perfect pixel boundaries. -- Supports non-square pixel aspect ratios. (WIP) +- Supports non-square pixel aspect ratios. ## Examples @@ -36,6 +36,7 @@ The Minimum Supported Rust Version for `pixels` will always be made available in - [Minimal example with SDL2](./examples/minimal-sdl2) - [Minimal example with `winit`](./examples/minimal-winit) - [Minimal example with `fltk`](./examples/minimal-fltk) +- [Non-square pixel aspect ratios](./examples/pixel-aspect-ratio) - [Pixel Invaders](./examples/invaders) - [`raqote` example](./examples/raqote-winit) diff --git a/examples/minimal-fltk/src/main.rs b/examples/minimal-fltk/src/main.rs index 128ba480..f57c9481 100644 --- a/examples/minimal-fltk/src/main.rs +++ b/examples/minimal-fltk/src/main.rs @@ -98,12 +98,13 @@ impl World { 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 d = { - let xd = x as i32 - self.circle_x as i32; - let yd = y as i32 - self.circle_y as i32; - ((xd.pow(2) + yd.pow(2)) as f64).sqrt().powi(2) + let length = { + let x = (x - self.circle_x) as f64; + let y = (y - self.circle_y) as f64; + + x.powf(2.0) + y.powf(2.0) }; - let inside_the_circle = d < (CIRCLE_RADIUS as f64).powi(2); + let inside_the_circle = length < (CIRCLE_RADIUS as f64).powi(2); let rgba = if inside_the_circle { [0xac, 0x00, 0xe6, 0xff] diff --git a/examples/pixel-aspect-ratio/Cargo.toml b/examples/pixel-aspect-ratio/Cargo.toml new file mode 100644 index 00000000..aaf4732b --- /dev/null +++ b/examples/pixel-aspect-ratio/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "pixel-aspect-ratio" +version = "0.1.0" +authors = ["Jay Oster "] +edition = "2018" +publish = false + +[features] +optimize = ["log/release_max_level_warn"] +default = ["optimize"] + +[dependencies] +env_logger = "0.8" +log = "0.4" +pixels = { path = "../.." } +winit = "0.24" +winit_input_helper = "0.9" diff --git a/examples/pixel-aspect-ratio/README.md b/examples/pixel-aspect-ratio/README.md new file mode 100644 index 00000000..4ab1ebc7 --- /dev/null +++ b/examples/pixel-aspect-ratio/README.md @@ -0,0 +1,19 @@ +# Hello Pixel Aspect Ratio + +![Hello Pixel Aspect Ratio](../../img/pixel-aspect-ratio.png) + +## Running + +```bash +cargo run --release --package pixel-aspect-ratio +``` + +## About + +This example demonstrates pixel aspect ratios. PAR is similar to the common screen aspect ratio that many people are now familiar with (e.g. `16:9` wide screen displays), but applies to the ratio of a _pixel_'s width to its height instead of the screen. Pixel aspect ratios other than `1:1` are common on old computer and video game hardware that outputs NTSC or PAL video signals. + +The screenshot above shows an ellipse with an `8:7` aspect ratio drawn on a pixel buffer with a matching pixel aspect ratio. In other words, it shows a circle! Below, the _same_ pixel buffer is rendered with a `1:1` pixel aspect ratio, which shows the actual distortion of the ellipse. + +![Original Ellipse](../../img/pixel-aspect-ratio-2.png) + +You might also take note that the window is slightly wider in the first image. This is ultimately what corrects the distortion and causes the ellipse to look like a circle. diff --git a/examples/pixel-aspect-ratio/src/main.rs b/examples/pixel-aspect-ratio/src/main.rs new file mode 100644 index 00000000..8772d429 --- /dev/null +++ b/examples/pixel-aspect-ratio/src/main.rs @@ -0,0 +1,142 @@ +#![deny(clippy::all)] +#![forbid(unsafe_code)] + +use log::error; +use pixels::{Error, PixelsBuilder, 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; + +const WIDTH: u32 = 320; +const HEIGHT: u32 = 240; + +// The circle is actually defined as an ellipse with minor axis 56 pixels and major axis 64 pixels. +const CIRCLE_AXES: (i16, i16) = (56, 64); +const CIRCLE_SEMI: (i16, i16) = (CIRCLE_AXES.0 / 2, CIRCLE_AXES.1 / 2); + +// The Pixel Aspect Ratio is the difference between the physical width and height of a single pixel. +// For most users, this ratio will be 1:1, i.e. the value will be `1.0`. Some devices display +// non-square pixels, and the pixel aspect ratio can simulate this difference on devices with square +// pixels. In this example, the ellipse will be rendered as a circle if it is drawn with a pixel +// aspect ratio of 8:7. +const PAR: f32 = 8.0 / 7.0; + +/// Representation of the application state. In this example, a circle will bounce around the screen. +struct World { + circle_x: i16, + circle_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 = { + // The window size is horizontally stretched by the PAR. + let size = LogicalSize::new(WIDTH as f64 * PAR as f64, HEIGHT as f64); + WindowBuilder::new() + .with_title("Hello Pixel Aspect Ratio") + .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); + PixelsBuilder::new(WIDTH, HEIGHT, surface_texture) + .pixel_aspect_ratio(PAR) + .build()? + }; + let mut world = World::new(); + + event_loop.run(move |event, _, control_flow| { + // Draw the current frame + if let Event::RedrawRequested(_) = event { + world.draw(pixels.get_frame()); + if pixels + .render() + .map_err(|e| error!("pixels.render() 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); + } + + // Update internal state and request a redraw + world.update(); + window.request_redraw(); + } + }); +} + +impl World { + /// Create a new `World` instance that can draw a moving circle. + fn new() -> Self { + Self { + circle_x: CIRCLE_SEMI.0 + 24, + circle_y: CIRCLE_SEMI.1 + 16, + velocity_x: 1, + velocity_y: 1, + } + } + + /// Update the `World` internal state; bounce the circle around the screen. + fn update(&mut self) { + if self.circle_x - CIRCLE_SEMI.0 <= 0 || self.circle_x + CIRCLE_SEMI.0 > WIDTH as i16 { + self.velocity_x *= -1; + } + if self.circle_y - CIRCLE_SEMI.1 <= 0 || self.circle_y + CIRCLE_SEMI.1 > HEIGHT as i16 { + self.velocity_y *= -1; + } + + self.circle_x += self.velocity_x; + self.circle_y += self.velocity_y; + } + + /// Draw the `World` state to the frame buffer. + /// + /// Assumes the default texture format: `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 length = { + let x = (x - self.circle_x) as f64; + let y = (y - self.circle_y) as f64; + let semi_minor = (CIRCLE_SEMI.0 as f64).powf(2.0); + let semi_major = (CIRCLE_SEMI.1 as f64).powf(2.0); + + x.powf(2.0) / semi_minor + y.powf(2.0) / semi_major + }; + let inside_the_circle = length < 1.0; + + let rgba = if inside_the_circle { + [0x5e, 0x48, 0xe8, 0xff] + } else { + [0x48, 0xb2, 0xe8, 0xff] + }; + + pixel.copy_from_slice(&rgba); + } + } +} diff --git a/img/pixel-aspect-ratio-2.png b/img/pixel-aspect-ratio-2.png new file mode 100644 index 00000000..fa3f2c07 Binary files /dev/null and b/img/pixel-aspect-ratio-2.png differ diff --git a/img/pixel-aspect-ratio.png b/img/pixel-aspect-ratio.png new file mode 100644 index 00000000..666bf099 Binary files /dev/null and b/img/pixel-aspect-ratio.png differ diff --git a/src/builder.rs b/src/builder.rs index 57563375..b74a4c2c 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -10,7 +10,7 @@ pub struct PixelsBuilder<'req, 'dev, 'win, W: HasRawWindowHandle> { backend: wgpu::Backends, width: u32, height: u32, - _pixel_aspect_ratio: f64, + pixel_aspect_ratio: f32, present_mode: wgpu::PresentMode, surface_texture: SurfaceTexture<'win, W>, texture_format: wgpu::TextureFormat, @@ -62,7 +62,7 @@ impl<'req, 'dev, 'win, W: HasRawWindowHandle> PixelsBuilder<'req, 'dev, 'win, W> }), width, height, - _pixel_aspect_ratio: 1.0, + pixel_aspect_ratio: 1.0, present_mode: wgpu::PresentMode::Fifo, surface_texture, texture_format: wgpu::TextureFormat::Rgba8UnormSrgb, @@ -107,11 +107,10 @@ impl<'req, 'dev, 'win, W: HasRawWindowHandle> PixelsBuilder<'req, 'dev, 'win, W> /// # Warning /// /// This documentation is hidden because support for pixel aspect ratio is incomplete. - #[doc(hidden)] - pub fn pixel_aspect_ratio(mut self, pixel_aspect_ratio: f64) -> Self { + pub fn pixel_aspect_ratio(mut self, pixel_aspect_ratio: f32) -> Self { assert!(pixel_aspect_ratio > 0.0); - self._pixel_aspect_ratio = pixel_aspect_ratio; + self.pixel_aspect_ratio = pixel_aspect_ratio; self } @@ -187,7 +186,8 @@ impl<'req, 'dev, 'win, W: HasRawWindowHandle> PixelsBuilder<'req, 'dev, 'win, W> async fn build_impl(self) -> Result { let instance = wgpu::Instance::new(self.backend); - // TODO: Use `options.pixel_aspect_ratio` to stretch the scaled texture + let pixel_aspect_ratio = self.pixel_aspect_ratio; + let texture_format = self.texture_format; let surface = unsafe { instance.create_surface(self.surface_texture.window) }; let compatible_surface = Some(&surface); let request_adapter_options = &self.request_adapter_options; @@ -241,6 +241,7 @@ impl<'req, 'dev, 'win, W: HasRawWindowHandle> PixelsBuilder<'req, 'dev, 'win, W> // Backing texture values self.width, self.height, + pixel_aspect_ratio, self.texture_format, // Render texture values &surface_size, @@ -258,13 +259,14 @@ impl<'req, 'dev, 'win, W: HasRawWindowHandle> PixelsBuilder<'req, 'dev, 'win, W> surface, texture, texture_extent, - texture_format: self.texture_format, - texture_format_size: get_texture_format_size(self.texture_format), + texture_format, + texture_format_size: get_texture_format_size(texture_format), scaling_renderer, }; let pixels = Pixels { context, + pixel_aspect_ratio, surface_size, present_mode, render_texture_format, @@ -320,6 +322,7 @@ pub(crate) fn create_backing_texture( device: &wgpu::Device, width: u32, height: u32, + pixel_aspect_ratio: f32, backing_texture_format: wgpu::TextureFormat, surface_size: &SurfaceSize, render_texture_format: wgpu::TextureFormat, @@ -331,7 +334,7 @@ pub(crate) fn create_backing_texture( usize, ) { let scaling_matrix_inverse = ScalingMatrix::new( - (width as f32, height as f32), + (width as f32, height as f32, pixel_aspect_ratio), (surface_size.width as f32, surface_size.height as f32), ) .transform @@ -358,6 +361,7 @@ pub(crate) fn create_backing_texture( device, &texture_view, &texture_extent, + pixel_aspect_ratio, surface_size, render_texture_format, ); diff --git a/src/lib.rs b/src/lib.rs index 920e8735..cea049f4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -94,6 +94,7 @@ pub struct PixelsContext { #[derive(Debug)] pub struct Pixels { context: PixelsContext, + pixel_aspect_ratio: f32, surface_size: SurfaceSize, present_mode: wgpu::PresentMode, render_texture_format: wgpu::TextureFormat, @@ -258,6 +259,7 @@ impl Pixels { // Backing texture values width, height, + self.pixel_aspect_ratio, self.context.texture_format, // Render texture values &self.surface_size, @@ -299,6 +301,7 @@ impl Pixels { ( self.context.texture_extent.width as f32, self.context.texture_extent.height as f32, + self.pixel_aspect_ratio, ), (width as f32, height as f32), ) diff --git a/src/renderers.rs b/src/renderers.rs index 79c577a9..4952b900 100644 --- a/src/renderers.rs +++ b/src/renderers.rs @@ -9,8 +9,7 @@ pub struct ScalingRenderer { uniform_buffer: wgpu::Buffer, bind_group: wgpu::BindGroup, render_pipeline: wgpu::RenderPipeline, - width: f32, - height: f32, + texture_size: (f32, f32, f32), clip_rect: (u32, u32, u32, u32), } @@ -19,12 +18,19 @@ impl ScalingRenderer { device: &wgpu::Device, texture_view: &wgpu::TextureView, texture_size: &wgpu::Extent3d, + pixel_aspect_ratio: f32, surface_size: &SurfaceSize, render_texture_format: wgpu::TextureFormat, ) -> Self { let shader = wgpu::include_wgsl!("../shaders/scale.wgsl"); let module = device.create_shader_module(&shader); + let texture_size = ( + texture_size.width as f32, + texture_size.height as f32, + pixel_aspect_ratio, + ); + // Create a texture sampler with nearest neighbor let sampler = device.create_sampler(&wgpu::SamplerDescriptor { label: Some("pixels_scaling_renderer_sampler"), @@ -67,7 +73,7 @@ impl ScalingRenderer { // Create uniform buffer let matrix = ScalingMatrix::new( - (texture_size.width as f32, texture_size.height as f32), + texture_size, (surface_size.width as f32, surface_size.height as f32), ); let transform_bytes = matrix.as_bytes(); @@ -170,8 +176,7 @@ impl ScalingRenderer { uniform_buffer, bind_group, render_pipeline, - width: texture_size.width as f32, - height: texture_size.height as f32, + texture_size, clip_rect, } } @@ -210,7 +215,7 @@ 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 matrix = ScalingMatrix::new(self.texture_size, (width as f32, height as f32)); let transform_bytes = matrix.as_bytes(); queue.write_buffer(&self.uniform_buffer, 0, transform_bytes); @@ -218,6 +223,8 @@ impl ScalingRenderer { } } +/// The scaling matrix is used by the default `ScalingRenderer` to add a border which maintains the +/// texture aspect ratio and integer scaling. #[derive(Debug)] pub(crate) struct ScalingMatrix { pub(crate) transform: Mat4, @@ -225,12 +232,18 @@ pub(crate) struct ScalingMatrix { } 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 { - let (texture_width, texture_height) = texture_size; + /// Create a new `ScalingMatrix`. + /// + /// Takes two sizes: pixel buffer texture size and surface texture size. Both are defined in + /// physical pixel units. The pixel buffer texture size also expects the pixel aspect ratio as + /// the third field. The PAR allows the pixel buffer texture to be rendered with non-square + /// pixels. + pub(crate) fn new(texture_size: (f32, f32, f32), screen_size: (f32, f32)) -> Self { + let (texture_width, texture_height, pixel_aspect_ratio) = texture_size; let (screen_width, screen_height) = screen_size; + let texture_width = texture_width * pixel_aspect_ratio; + // Get smallest scale size let scale = (screen_width / texture_width) .min(screen_height / texture_height) @@ -269,6 +282,7 @@ impl ScalingMatrix { } } + /// Get a byte slice representation of the matrix suitable for copying to a `wgpu` buffer. fn as_bytes(&self) -> &[u8] { self.transform.as_byte_slice() }