diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5f88b58a..797c7b86 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,9 +32,42 @@ jobs: with: command: check args: --all + lints: + name: Lints + runs-on: ubuntu-latest + steps: + - name: Checkout sources + uses: actions/checkout@v2 + - name: Update apt repos + run: sudo apt-get -y update + - name: Install dependencies + run: sudo apt -y install libsdl2-dev + - name: Install toolchain + uses: actions-rs/toolchain@v1 + with: + profile: minimal + toolchain: stable + components: clippy, rustfmt + override: true + - name: Cargo fmt + uses: actions-rs/cargo@v1 + with: + command: fmt + args: --all -- --check + - name: Cargo doc + uses: actions-rs/cargo@v1 + with: + command: doc + args: --workspace --no-deps + - name: Cargo clippy + uses: actions-rs/clippy-check@v1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + args: --all --tests -- -D warnings tests: name: Test runs-on: ubuntu-latest + needs: [checks, lints] strategy: matrix: rust: @@ -59,9 +92,14 @@ jobs: with: command: test args: --all - lints: - name: Lints + wasm: + name: WASM runs-on: ubuntu-latest + needs: [checks, lints] + strategy: + matrix: + example: + - minimal-web steps: - name: Checkout sources uses: actions/checkout@v2 @@ -74,20 +112,9 @@ jobs: with: profile: minimal toolchain: stable - components: clippy, rustfmt + target: wasm32-unknown-unknown override: true - - name: Cargo fmt - uses: actions-rs/cargo@v1 - with: - command: fmt - args: --all -- --check - - name: Cargo doc - uses: actions-rs/cargo@v1 - with: - command: doc - args: --workspace --no-deps - - name: Cargo clippy - uses: actions-rs/clippy-check@v1 - with: - token: ${{ secrets.GITHUB_TOKEN }} - args: --all --tests -- -D warnings + - name: Install tools + run: cargo install --locked wasm-bindgen-cli just + - name: Just build + run: just build ${{ matrix.example }} diff --git a/Cargo.toml b/Cargo.toml index 8da02841..75402d29 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,12 +20,14 @@ include = [ [dependencies] bytemuck = "1.7" -pollster = "0.2" raw-window-handle = "0.3" thiserror = "1.0" ultraviolet = "0.8" wgpu = "0.11" +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +pollster = "0.2" + [dev-dependencies] pixels-mocks = { path = "internals/pixels-mocks" } winit = "0.25" diff --git a/README.md b/README.md index 8ff94d9b..6ba0b809 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ The Minimum Supported Rust Version for `pixels` will always be made available in - [Custom Shader](./examples/custom-shader) - [Dear ImGui example with `winit`](./examples/imgui-winit) - [Egui example with `winit`](./examples/minimal-egui) +- [Minimal example for WebGL2](./examples/minimal-web) - [Minimal example with SDL2](./examples/minimal-sdl2) - [Minimal example with `winit`](./examples/minimal-winit) - [Minimal example with `fltk`](./examples/minimal-fltk) diff --git a/examples/minimal-web/Cargo.toml b/examples/minimal-web/Cargo.toml new file mode 100644 index 00000000..1b72dfb0 --- /dev/null +++ b/examples/minimal-web/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "minimal-web" +version = "0.1.0" +authors = ["Jay Oster "] +edition = "2018" +resolver = "2" +publish = false + +[features] +optimize = ["log/release_max_level_warn"] +web = ["wgpu/webgl", "winit/web-sys"] +default = ["optimize"] + +[dependencies] +log = "0.4" +pixels = { path = "../.." } +wgpu = "0.11" +winit = "0.25" +winit_input_helper = "0.10" + +[target.'cfg(target_arch = "wasm32")'.dependencies] +console_error_panic_hook = "0.1" +console_log = "0.2" +wasm-bindgen = "0.2.78" +wasm-bindgen-futures = "0.4" +web-sys = "0.3" + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +env_logger = "0.9" +pollster = "0.2" diff --git a/examples/minimal-web/README.md b/examples/minimal-web/README.md new file mode 100644 index 00000000..09a1be67 --- /dev/null +++ b/examples/minimal-web/README.md @@ -0,0 +1,42 @@ +# Hello Pixels + Web + +![Hello Pixels + Web](../../img/minimal-web.png) + +Minimal example for WebGL2. + +## Install build dependencies + +Install the WASM32 target and a few CLI tools: + +```bash +rustup target add wasm32-unknown-unknown +cargo install --locked wasm-bindgen-cli just miniserve +``` + +## Running on the Web + +Build the project and start a local server to host it: + +```bash +just serve minimal-web +``` + +Open http://localhost:8080/ in your browser to run the example. + +To build the project without serving it: + +```bash +just build minimal-web +``` + +The build files are stored in `./target/minimal-web/`. + +## Running on native targets + +```bash +cargo run --release --package minimal-web +``` + +## About + +This example is based on `minimal-winit`, demonstrating how to build your app for WebGL2 targets. diff --git a/examples/minimal-web/index.html b/examples/minimal-web/index.html new file mode 100644 index 00000000..d19bf557 --- /dev/null +++ b/examples/minimal-web/index.html @@ -0,0 +1,24 @@ + + + + + + + + Hello Pixels + Web + + + + + diff --git a/examples/minimal-web/src/main.rs b/examples/minimal-web/src/main.rs new file mode 100644 index 00000000..e5ae4954 --- /dev/null +++ b/examples/minimal-web/src/main.rs @@ -0,0 +1,190 @@ +#![deny(clippy::all)] +#![forbid(unsafe_code)] + +use log::error; +use pixels::{Pixels, SurfaceTexture}; +use std::rc::Rc; +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; +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() { + #[cfg(target_arch = "wasm32")] + { + std::panic::set_hook(Box::new(console_error_panic_hook::hook)); + console_log::init_with_level(log::Level::Trace).expect("error initializing logger"); + + wasm_bindgen_futures::spawn_local(run()); + } + + #[cfg(not(target_arch = "wasm32"))] + { + env_logger::init(); + + pollster::block_on(run()); + } +} + +async fn run() { + let event_loop = EventLoop::new(); + let window = { + let size = LogicalSize::new(WIDTH as f64, HEIGHT as f64); + WindowBuilder::new() + .with_title("Hello Pixels + Web") + .with_inner_size(size) + .with_min_inner_size(size) + .build(&event_loop) + .expect("WindowBuilder error") + }; + + let window = Rc::new(window); + + #[cfg(target_arch = "wasm32")] + { + use wasm_bindgen::JsCast; + use winit::platform::web::WindowExtWebSys; + + // Retrieve current width and height dimensions of browser client window + let get_window_size = || { + let client_window = web_sys::window().unwrap(); + LogicalSize::new( + client_window.inner_width().unwrap().as_f64().unwrap(), + client_window.inner_height().unwrap().as_f64().unwrap(), + ) + }; + + let window = Rc::clone(&window); + + // Initialize winit window with current dimensions of browser client + window.set_inner_size(get_window_size()); + + let client_window = web_sys::window().unwrap(); + + // Attach winit canvas to body element + web_sys::window() + .and_then(|win| win.document()) + .and_then(|doc| doc.body()) + .and_then(|body| { + body.append_child(&web_sys::Element::from(window.canvas())) + .ok() + }) + .expect("couldn't append canvas to document body"); + + // Listen for resize event on browser client. Adjust winit window dimensions + // on event trigger + let closure = wasm_bindgen::closure::Closure::wrap(Box::new(move |_e: web_sys::Event| { + let size = get_window_size(); + window.set_inner_size(size) + }) as Box); + client_window + .add_event_listener_with_callback("resize", closure.as_ref().unchecked_ref()) + .unwrap(); + closure.forget(); + } + + let mut input = WinitInputHelper::new(); + let mut pixels = { + let window_size = window.inner_size(); + let surface_texture = + SurfaceTexture::new(window_size.width, window_size.height, window.as_ref()); + Pixels::new_async(WIDTH, HEIGHT, surface_texture) + .await + .expect("Pixels error") + }; + 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 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: `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/img/minimal-web.png b/img/minimal-web.png new file mode 100644 index 00000000..27656e89 Binary files /dev/null and b/img/minimal-web.png differ diff --git a/justfile b/justfile new file mode 100644 index 00000000..c65d0fa4 --- /dev/null +++ b/justfile @@ -0,0 +1,11 @@ +serve package: (build package) + miniserve --index index.html ./target/{{package}}/ + +build package: + mkdir -p ./target/{{package}}/ + cp ./examples/{{package}}/index.html ./target/{{package}}/ + cargo build --release --package {{package}} --target wasm32-unknown-unknown --features web + wasm-bindgen --target web --no-typescript --out-dir ./target/{{package}}/ ./target/wasm32-unknown-unknown/release/{{package}}.wasm + +clean package: + rm -rf ./target/{{package}}/ diff --git a/src/builder.rs b/src/builder.rs index aac5a50b..57563375 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -23,12 +23,14 @@ impl<'req, 'dev, 'win, W: HasRawWindowHandle> PixelsBuilder<'req, 'dev, 'win, W> /// # Examples /// /// ```no_run + /// use pixels::wgpu::{PowerPreference, RequestAdapterOptions}; + /// /// # use pixels::PixelsBuilder; /// # let window = pixels_mocks::Rwh; - /// # let surface_texture = pixels::SurfaceTexture::new(1024, 768, &window); + /// # let surface_texture = pixels::SurfaceTexture::new(256, 240, &window); /// let mut pixels = PixelsBuilder::new(256, 240, surface_texture) - /// .request_adapter_options(wgpu::RequestAdapterOptions { - /// power_preference: wgpu::PowerPreference::HighPerformance, + /// .request_adapter_options(RequestAdapterOptions { + /// power_preference: PowerPreference::HighPerformance, /// force_fallback_adapter: false, /// compatible_surface: None, /// }) @@ -47,7 +49,17 @@ impl<'req, 'dev, 'win, W: HasRawWindowHandle> PixelsBuilder<'req, 'dev, 'win, W> Self { request_adapter_options: None, device_descriptor: None, - backend: wgpu::util::backend_bits_from_env().unwrap_or(wgpu::Backends::PRIMARY), + backend: wgpu::util::backend_bits_from_env().unwrap_or({ + #[cfg(not(target_arch = "wasm32"))] + { + wgpu::Backends::PRIMARY + } + + #[cfg(target_arch = "wasm32")] + { + wgpu::Backends::all() + } + }), width, height, _pixel_aspect_ratio: 1.0, @@ -166,20 +178,24 @@ impl<'req, 'dev, 'win, W: HasRawWindowHandle> PixelsBuilder<'req, 'dev, 'win, W> /// Create a pixel buffer from the options builder. /// + /// This is the private implementation shared by [`PixelsBuilder::build`] and + /// [`PixelsBuilder::build_async`]. + /// /// # Errors /// /// Returns an error when a [`wgpu::Adapter`] cannot be found. - pub fn build(self) -> Result { + 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 surface = unsafe { instance.create_surface(self.surface_texture.window) }; let compatible_surface = Some(&surface); let request_adapter_options = &self.request_adapter_options; - let adapter = - wgpu::util::initialize_adapter_from_env(&instance, self.backend).or_else(|| { - let future = - instance.request_adapter(&request_adapter_options.as_ref().map_or_else( + let adapter = match wgpu::util::initialize_adapter_from_env(&instance, self.backend) { + Some(adapter) => Some(adapter), + None => { + instance + .request_adapter(&request_adapter_options.as_ref().map_or_else( || wgpu::RequestAdapterOptions { compatible_surface, force_fallback_adapter: false, @@ -188,13 +204,14 @@ impl<'req, 'dev, 'win, W: HasRawWindowHandle> PixelsBuilder<'req, 'dev, 'win, W> }, |rao| wgpu::RequestAdapterOptions { compatible_surface: rao.compatible_surface.or(compatible_surface), - force_fallback_adapter: false, + force_fallback_adapter: rao.force_fallback_adapter, power_preference: rao.power_preference, }, - )); + )) + .await + } + }; - pollster::block_on(future) - }); let adapter = adapter.ok_or(Error::AdapterNotFound)?; let device_descriptor = self @@ -204,7 +221,9 @@ impl<'req, 'dev, 'win, W: HasRawWindowHandle> PixelsBuilder<'req, 'dev, 'win, W> ..wgpu::DeviceDescriptor::default() }); - let (device, queue) = pollster::block_on(adapter.request_device(&device_descriptor, None)) + let (device, queue) = adapter + .request_device(&device_descriptor, None) + .await .map_err(Error::DeviceNotFound)?; let present_mode = self.present_mode; @@ -256,6 +275,45 @@ impl<'req, 'dev, 'win, W: HasRawWindowHandle> PixelsBuilder<'req, 'dev, 'win, W> Ok(pixels) } + + /// Create a pixel buffer from the options builder. + /// + /// This method blocks the current thread, making it unusable on Web targets. Use + /// [`PixelsBuilder::build_async`] for a non-blocking alternative. + /// + /// # Errors + /// + /// Returns an error when a [`wgpu::Adapter`] or [`wgpu::Device`] cannot be found. + #[cfg(not(target_arch = "wasm32"))] + pub fn build(self) -> Result { + pollster::block_on(self.build_impl()) + } + + /// Create a pixel buffer from the options builder without blocking the current thread. + /// + /// # Examples + /// + /// ```no_run + /// use pixels::wgpu::{Backends, DeviceDescriptor, Limits}; + /// + /// # async fn test() -> Result<(), pixels::Error> { + /// # use pixels::PixelsBuilder; + /// # let window = pixels_mocks::Rwh; + /// # let surface_texture = pixels::SurfaceTexture::new(256, 240, &window); + /// let mut pixels = PixelsBuilder::new(256, 240, surface_texture) + /// .enable_vsync(false) + /// .build_async() + /// .await?; + /// # Ok::<(), pixels::Error>(()) + /// # } + /// ``` + /// + /// # Errors + /// + /// Returns an error when a [`wgpu::Adapter`] or [`wgpu::Device`] cannot be found. + pub async fn build_async(self) -> Result { + self.build_impl().await + } } pub(crate) fn create_backing_texture( diff --git a/src/lib.rs b/src/lib.rs index 2bffa120..ebb8c81f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -142,10 +142,7 @@ impl<'win, W: HasRawWindowHandle> SurfaceTexture<'win, W> { /// let window = Window::new(&event_loop).unwrap(); /// let size = window.inner_size(); /// - /// let width = size.width; - /// let height = size.height; - /// - /// let surface_texture = SurfaceTexture::new(width, height, &window); + /// let surface_texture = SurfaceTexture::new(size.width, size.height, &window); /// # Ok::<(), pixels::Error>(()) /// ``` /// @@ -173,12 +170,15 @@ impl Pixels { /// `320x240`, `640x480`, `960x720`, etc. without adding a border because these are exactly /// 1x, 2x, and 3x scales, respectively. /// + /// This method blocks the current thread, making it unusable on Web targets. Use + /// [`Pixels::new_async`] for a non-blocking alternative. + /// /// # Examples /// /// ```no_run /// # use pixels::Pixels; /// # let window = pixels_mocks::Rwh; - /// # let surface_texture = pixels::SurfaceTexture::new(1024, 768, &window); + /// # let surface_texture = pixels::SurfaceTexture::new(320, 240, &window); /// let mut pixels = Pixels::new(320, 240, surface_texture)?; /// # Ok::<(), pixels::Error>(()) /// ``` @@ -190,6 +190,7 @@ impl Pixels { /// # Panics /// /// Panics when `width` or `height` are 0. + #[cfg(not(target_arch = "wasm32"))] pub fn new( width: u32, height: u32, @@ -198,6 +199,39 @@ impl Pixels { PixelsBuilder::new(width, height, surface_texture).build() } + /// Asynchronously create a pixel buffer instance with default options. + /// + /// See [`Pixels::new`] for more information. + /// + /// # Examples + /// + /// ```no_run + /// # async fn test() -> Result<(), pixels::Error> { + /// # use pixels::Pixels; + /// # let window = pixels_mocks::Rwh; + /// # let surface_texture = pixels::SurfaceTexture::new(320, 240, &window); + /// let mut pixels = Pixels::new_async(320, 240, surface_texture).await?; + /// # Ok::<(), pixels::Error>(()) + /// # } + /// ``` + /// + /// # Errors + /// + /// Returns an error when a [`wgpu::Adapter`] cannot be found. + /// + /// # Panics + /// + /// Panics when `width` or `height` are 0. + pub async fn new_async( + width: u32, + height: u32, + surface_texture: SurfaceTexture<'_, W>, + ) -> Result { + PixelsBuilder::new(width, height, surface_texture) + .build_async() + .await + } + /// Resize the pixel buffer and zero its contents. /// /// This does not resize the surface upon which the pixel buffer texture is rendered. Use @@ -291,7 +325,7 @@ impl Pixels { /// ```no_run /// # use pixels::Pixels; /// # let window = pixels_mocks::Rwh; - /// # let surface_texture = pixels::SurfaceTexture::new(1024, 768, &window); + /// # let surface_texture = pixels::SurfaceTexture::new(320, 240, &window); /// let mut pixels = Pixels::new(320, 240, surface_texture)?; /// /// // Clear the pixel buffer @@ -336,7 +370,7 @@ impl Pixels { /// ```no_run /// # use pixels::Pixels; /// # let window = pixels_mocks::Rwh; - /// # let surface_texture = pixels::SurfaceTexture::new(1024, 768, &window); + /// # let surface_texture = pixels::SurfaceTexture::new(320, 240, &window); /// let mut pixels = Pixels::new(320, 240, surface_texture)?; /// /// // Clear the pixel buffer @@ -449,16 +483,15 @@ impl Pixels { /// the screen, using isize instead of usize. /// /// ```no_run + /// use winit::dpi::PhysicalPosition; + /// /// # use pixels::Pixels; /// # let window = pixels_mocks::Rwh; - /// # let surface_texture = pixels::SurfaceTexture::new(1024, 768, &window); - /// const WIDTH: u32 = 320; - /// const HEIGHT: u32 = 240; - /// - /// let mut pixels = Pixels::new(WIDTH, HEIGHT, surface_texture)?; + /// # let surface_texture = pixels::SurfaceTexture::new(320, 240, &window); + /// let mut pixels = Pixels::new(320, 240, surface_texture)?; /// /// // A cursor position in physical units - /// let cursor_position: (f32, f32) = winit::dpi::PhysicalPosition::new(0.0, 0.0).into(); + /// let cursor_position: (f32, f32) = PhysicalPosition::new(0.0, 0.0).into(); /// /// // Convert it to a pixel location /// let pixel_position: (usize, usize) = pixels.window_pos_to_pixel(cursor_position) @@ -509,11 +542,8 @@ impl Pixels { /// ```no_run /// # use pixels::Pixels; /// # let window = pixels_mocks::Rwh; - /// # let surface_texture = pixels::SurfaceTexture::new(1024, 768, &window); - /// const WIDTH: u32 = 320; - /// const HEIGHT: u32 = 240; - /// - /// let mut pixels = Pixels::new(WIDTH, HEIGHT, surface_texture)?; + /// # let surface_texture = pixels::SurfaceTexture::new(320, 240, &window); + /// let mut pixels = Pixels::new(320, 240, surface_texture)?; /// /// let pixel_pos = pixels.clamp_pixel_pos((-19, 20)); /// assert_eq!(pixel_pos, (0, 20));