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();