diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5342a2c..df31e2a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: - uses: actions-rs/cargo@v1 with: command: check - args: --all + args: --all --exclude web test: name: Test Suite diff --git a/.gitignore b/.gitignore index ac3dd4f..54c72f2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,12 @@ # Cargo specific. /target Cargo.lock + +# wasm-pack, npm, Parcel. +/web/.parcel-cache +/web/dist +/web/node_modules +/web/package-lock.json /web/pkg # Editors and IDEs. @@ -8,4 +14,4 @@ Cargo.lock .vscode # Misc. -.DS_Store \ No newline at end of file +.DS_Store diff --git a/Cargo.toml b/Cargo.toml index 0069957..b41e379 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ members = [ "demo", "e2e-tests", "forma", + "web", ] resolver = "2" diff --git a/forma/src/lib.rs b/forma/src/lib.rs index 4c62a15..81d5c96 100644 --- a/forma/src/lib.rs +++ b/forma/src/lib.rs @@ -142,6 +142,7 @@ pub mod prelude { }, Channel, BGR0, BGR1, BGRA, RGB0, RGB1, RGBA, }; + #[cfg(feature = "gpu")] pub use crate::gpu::Timings; pub use crate::{ math::{AffineTransform, GeomPresTransform, Point}, diff --git a/web/.cargo/config.toml b/web/.cargo/config.toml new file mode 100644 index 0000000..8d1b839 --- /dev/null +++ b/web/.cargo/config.toml @@ -0,0 +1,5 @@ +[target.wasm32-unknown-unknown] +rustflags = ["-C", "target-feature=+simd128,+atomics,+bulk-memory,+mutable-globals"] + +[unstable] +build-std = ["panic_abort", "std"] diff --git a/web/.proxyrc.js b/web/.proxyrc.js new file mode 100644 index 0000000..8074293 --- /dev/null +++ b/web/.proxyrc.js @@ -0,0 +1,22 @@ +// Copyright 2022 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +module.exports = function (app) { + app.use((req, res, next) => { + res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp'); + res.setHeader('Cross-Origin-Opener-Policy', 'same-origin'); + + next(); + }); +} diff --git a/web/Cargo.toml b/web/Cargo.toml new file mode 100644 index 0000000..920d2d2 --- /dev/null +++ b/web/Cargo.toml @@ -0,0 +1,45 @@ +# Copyright 2022 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +[package] +name = "web" +version = "0.1.0" +edition = "2021" + +[package.metadata.wasm-pack.profile.release] +wasm-opt = false + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +default = ["console_error_panic_hook"] + +[dependencies] +forma = { path = "../forma", package = "forma-render", default-features = false } +getrandom = { version = "0.2", features = ["js"] } +nalgebra = "0.31.4" +rand = { version = "0.8", features = ["small_rng"] } +wasm-bindgen = "0.2.63" +wasm-bindgen-rayon = "1.0" +winit = "0.27.1" + +# The `console_error_panic_hook` crate provides better debugging of panics by +# logging them with `console.error`. This is great for development, but requires +# all the `std::fmt` and `std::panicking` infrastructure, so isn't great for +# code size when deploying. +console_error_panic_hook = { version = "0.1.6", optional = true } + +[dev-dependencies] +wasm-bindgen-test = "0.3.13" diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..219605f --- /dev/null +++ b/web/README.md @@ -0,0 +1,8 @@ +You can run the web demo with: + +```sh +npm install +npm run build +npm start +``` + diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..b97513d --- /dev/null +++ b/web/index.html @@ -0,0 +1,29 @@ + + + + + + +
+ + +

FPS: ??? (???/???)

+ + +
+ + diff --git a/web/index.js b/web/index.js new file mode 100644 index 0000000..a418413 --- /dev/null +++ b/web/index.js @@ -0,0 +1,114 @@ +// Copyright 2022 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +const partials = document.getElementById('partials'); + +const canvas = document.getElementById('drawing'); +const ctx = canvas.getContext('2d'); + +var controls = 0b0000; + +document.addEventListener('keydown', (event) => { + switch (event.key) { + case "Up": + case "ArrowUp": + controls |= 0b1000; + break; + case "Right": + case "ArrowRight": + controls |= 0b0100; + break; + case "Down": + case "ArrowDown": + controls |= 0b0010; + break; + case "Left": + case "ArrowLeft": + controls |= 0b0001; + break; + } +}, false); +document.addEventListener('keyup', (event) => { + switch (event.key) { + case "Up": + case "ArrowUp": + controls &= 0b0111; + break; + case "Right": + case "ArrowRight": + controls &= 0b1011; + break; + case "Down": + case "ArrowDown": + controls &= 0b1101; + break; + case "Left": + case "ArrowLeft": + controls &= 0b1110; + break; + } +}, false); + +const worker = new Worker(new URL('./worker.js', import.meta.url), { + type: 'module' +}); +worker.postMessage({ + 'width': canvas.width, + 'height': canvas.height, + 'partials': partials.checked, +}); + +const fps = document.getElementById('fps'); + +let timings = []; +function pushTiming(timing) { + timings.push(timing); + + if (timings.length == 50) { + const sum = timings.reduce((a, b) => a + b, 0); + const avg = (sum / timings.length) || 0; + const min = Math.min(...timings); + const max = Math.max(...timings); + + fps.innerHTML = 'FPS: ' + (1 / avg).toFixed(2) + ' (' + (1 / max).toFixed(2) + '/' + (1 / min).toFixed(2) + ')'; + + timings = []; + } +} + +let last_timestamp = 0; +function animation(timestamp) { + const elapsed = timestamp - last_timestamp; + last_timestamp = timestamp; + + pushTiming(elapsed / 1000); + + worker.postMessage({ + 'elapsed': elapsed, + 'width': canvas.width, + 'height': canvas.height, + 'partials': partials.checked, + 'controls': controls, + }); +} + +worker.onmessage = function(message) { + ctx.putImageData(new ImageData( + new Uint8ClampedArray(message.data), + canvas.width, + canvas.height + ), 0, 0); + + window.requestAnimationFrame(animation); +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..e7a33a7 --- /dev/null +++ b/web/package.json @@ -0,0 +1,10 @@ +{ + "source": "index.html", + "scripts": { + "start": "parcel", + "build": "wasm-pack build --target web && parcel build" + }, + "devDependencies": { + "parcel": "^2.8.2" + } +} diff --git a/web/rust-toolchain b/web/rust-toolchain new file mode 100644 index 0000000..bf867e0 --- /dev/null +++ b/web/rust-toolchain @@ -0,0 +1 @@ +nightly diff --git a/web/src/lib.rs b/web/src/lib.rs new file mode 100644 index 0000000..2a00669 --- /dev/null +++ b/web/src/lib.rs @@ -0,0 +1,190 @@ +// Copyright 2022 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use std::{collections::HashSet, time::Duration}; + +use forma::{cpu, prelude::*}; +use wasm_bindgen::{prelude::*, Clamped}; + +#[path = "../../demo/src/demos/circles.rs"] +pub mod circles; +#[path = "../../demo/src/demos/spaceship.rs"] +pub mod spaceship; +mod utils; + +use circles::Circles; +use spaceship::Spaceship; +pub use wasm_bindgen_rayon::init_thread_pool; +use winit::event::VirtualKeyCode; + +struct Keyboard { + pressed: HashSet, +} + +impl Keyboard { + fn new() -> Self { + Self { + pressed: HashSet::new(), + } + } + + fn is_key_down(&self, key: VirtualKeyCode) -> bool { + self.pressed.contains(&key) + } +} + +trait App { + fn width(&self) -> usize; + fn height(&self) -> usize; + fn set_width(&mut self, width: usize); + fn set_height(&mut self, height: usize); + fn compose(&mut self, composition: &mut Composition, elapsed: Duration, keyboard: &Keyboard); +} + +#[wasm_bindgen] +pub struct Context { + composition: Composition, + renderer: cpu::Renderer, + layout: LinearLayout, + layer_cache: BufferLayerCache, + app: Box, + buffer: Vec, + width: usize, + height: usize, + was_cleared: bool, +} + +#[wasm_bindgen] +pub fn context_new_circles(width: usize, height: usize, count: usize) -> Context { + utils::set_panic_hook(); + + let buffer = vec![0u8; width * 4 * height]; + let layout = LinearLayout::new(width, width * 4, height); + + let composition = Composition::new(); + let mut renderer = cpu::Renderer::new(); + let layer_cache = renderer.create_buffer_layer_cache().unwrap(); + + let app: Box = Box::new(Circles::new(count)); + + Context { + composition, + renderer, + layout, + layer_cache, + app, + buffer, + width, + height, + was_cleared: false, + } +} + +#[wasm_bindgen] +pub fn context_new_spaceship(width: usize, height: usize) -> Context { + utils::set_panic_hook(); + + let buffer = vec![0u8; width * 4 * height]; + let layout = LinearLayout::new(width, width * 4, height); + + let composition = Composition::new(); + let mut renderer = cpu::Renderer::new(); + let layer_cache = renderer.create_buffer_layer_cache().unwrap(); + + let app: Box = Box::new(Spaceship::new()); + + Context { + composition, + renderer, + layout, + layer_cache, + app, + buffer, + width, + height, + was_cleared: false, + } +} + +#[wasm_bindgen] +pub fn context_draw( + context: &mut Context, + width: usize, + height: usize, + elapsed: f64, + force_clear: bool, + controls: u8, +) -> Clamped> { + if context.width != width || context.height != height { + context.buffer = vec![0u8; width * 4 * height]; + context.layout = LinearLayout::new(width, width * 4, height); + + context.app.set_width(width); + context.app.set_height(height); + } + + if force_clear { + for pixel in context.buffer.chunks_mut(4) { + pixel[0] = 255; + pixel[1] = 255; + pixel[2] = 255; + pixel[3] = 0; + } + + context.was_cleared = true; + } else { + if context.was_cleared { + context.layer_cache.clear(); + context.was_cleared = false; + } + } + + let mut pressed = HashSet::new(); + + if controls & 0b1000 != 0 { + pressed.insert(VirtualKeyCode::Up); + } + if controls & 0b0100 != 0 { + pressed.insert(VirtualKeyCode::Right); + } + if controls & 0b0010 != 0 { + pressed.insert(VirtualKeyCode::Down); + } + if controls & 0b0001 != 0 { + pressed.insert(VirtualKeyCode::Left); + } + + context.app.compose( + &mut context.composition, + Duration::from_secs_f64(elapsed / 1000.0), + &Keyboard { pressed }, + ); + + context.renderer.render( + &mut context.composition, + &mut BufferBuilder::new(&mut context.buffer, &mut context.layout) + .layer_cache(context.layer_cache.clone()) + .build(), + RGB1, + Color { + r: 1.0, + g: 1.0, + b: 1.0, + a: 0.0, + }, + None, + ); + + Clamped(context.buffer.clone()) +} diff --git a/web/src/utils.rs b/web/src/utils.rs new file mode 100644 index 0000000..2e88c12 --- /dev/null +++ b/web/src/utils.rs @@ -0,0 +1,24 @@ +// Copyright 2022 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +pub fn set_panic_hook() { + // When the `console_error_panic_hook` feature is enabled, we can call the + // `set_panic_hook` function at least once during initialization, and then + // we will get better error messages if our code ever panics. + // + // For more details see + // https://github.com/rustwasm/console_error_panic_hook#readme + #[cfg(feature = "console_error_panic_hook")] + console_error_panic_hook::set_once(); +} diff --git a/web/worker.js b/web/worker.js new file mode 100644 index 0000000..ff02092 --- /dev/null +++ b/web/worker.js @@ -0,0 +1,39 @@ +// Copyright 2022 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import init, { initThreadPool, context_draw, context_new_circles, context_new_spaceship } from './pkg/web.js'; + +let context; +let width = 1000; +let height = 1000; + +onmessage = function(message) { + width = message.data.width; + height = message.data.height; + + if (message.data.elapsed) { + postMessage(context_draw(context, width, height, message.data.elapsed, message.data.partials, message.data.controls)); + } +} + +async function initialize() { + await init(); + await initThreadPool(navigator.hardwareConcurrency); + + context = context_new_spaceship(width, height); + + postMessage(context_draw(context, width, height, 0, false, 0)); +} + +initialize();