Previous example: <--
web-sys: Wasm in Web Worker
Parallel Raytracing
This is an example of using threads with WebAssembly, Rust, and wasm-bindgen, culminating in a parallel raytracer demo. There's a number of moving pieces to this demo and it's unfortunately not the easiest thing to wrangle, but it's hoped that this'll give you a bit of a taste of what it's like to use threads and wasm with Rust on the web.
_ wasm-bindgen Guide{target="_blank"}
Parallel Raytracing{target="_blank"}
Building the demo
One of the major gotchas with threaded WebAssembly is that Rust does not ship a precompiled target (e.g. standard library) which has threading support enabled. This means that you'll need to recompile the standard library with the appropriate rustc flags, namely -C target-feature=+atomics,+bulk-memory,+mutable-globals. Note that this requires a nightly Rust toolchain.
_ wasm-bindgen Guide{target="_blank"}
setup the project
cargo new raytrace-parallel --lib
cd raytrace-parallel
mkdir -p www/js www/html
- Edit Cargo.toml
[lib]
crate-type = ["cdylib"]
[dependencies]
console_error_panic_hook = "0.1"
js-sys = "0.3.66"
rayon = "1.1.0"
rayon-core = "1.5.0"
raytracer = { git = 'https://github.com/alexcrichton/raytracer', branch = 'update-deps' }
serde-wasm-bindgen = "0.4.3"
futures-channel-preview = "0.3.0-alpha.18"
wasm-bindgen = "0.2.89"
wasm-bindgen-futures = "0.4.39"
[dependencies.web-sys]
version = "0.3.23"
features = [
'CanvasRenderingContext2d',
'ErrorEvent',
'Event',
'ImageData',
'Navigator',
'Window',
'Worker',
'DedicatedWorkerGlobalScope',
'MessageEvent',
]
# Replace command line
# cargo build --target wasm32-unknown-unknown -Z build-std=panic_abort,std
# and
# export RUSTFLAGS='-C target-feature=+atomics,+bulk-memory,+mutable-globals'
# with:
[unstable]
build-std = ['std', 'panic_abort']
[build]
target = "wasm32-unknown-unknown"
rustflags = '-Ctarget-feature=+atomics,+bulk-memory,+mutable-globals'
The code
- index.html
<!doctype html>
<html>
<head>
<meta content="text/html;charset=utf-8" http-equiv="Content-Type"/>
<title>Parallel Raytracing: no bundle</title>
<style>
#scene {
height: 100%;
width: 500px;
float: left;
}
#render, .concurrency, .timing {
padding: 20px;
margin: 20px;
float: left;
}
</style>
</head>
<body>
<textarea id='scene'>
{
"width": 800,
"height": 800,
"fov": 90.0,
"shadow_bias": 1e-13,
"max_recursion_depth": 20,
"elements": [
{
"Sphere" : {
"center": {
"x": 0.0,
"y": 0.0,
"z": -5.0
},
"radius": 1.0,
"material": {
"coloration" : {
"Color": {
"red": 0.2,
"green": 1.0,
"blue": 0.2
}
},
"albedo": 0.18,
"surface": {
"Reflective": {
"reflectivity": 0.7
}
}
}
}
},
{
"Sphere" : {
"center": {
"x": -3.0,
"y": 1.0,
"z": -6.0
},
"radius": 2.0,
"material": {
"coloration": {
"Color": {
"red": 1.0,
"green": 1.0,
"blue": 1.0
}
},
"albedo": 0.58,
"surface": "Diffuse"
}
}
},
{
"Sphere": {
"center": {
"x": 2.0,
"y": 1.0,
"z": -4.0
},
"radius": 1.5,
"material": {
"coloration": {
"Color": {
"red": 1.0,
"green": 1.0,
"blue": 1.0
}
},
"albedo": 0.18,
"surface": {
"Refractive": {
"index": 1.5,
"transparency": 1.0
}
}
}
}
},
{
"Plane": {
"origin": {
"x": 0.0,
"y": -2.0,
"z": -5.0
},
"normal": {
"x": 0.0,
"y": -1.0,
"z": 0.0
},
"material": {
"coloration": {
"Color": {
"red": 1.0,
"green": 1.0,
"blue": 1.0
}
},
"albedo": 0.18,
"surface": {
"Reflective": {
"reflectivity": 0.5
}
}
}
}
},
{
"Plane": {
"origin": {
"x": 0.0,
"y": 0.0,
"z": -20.0
},
"normal": {
"x": 0.0,
"y": 0.0,
"z": -1.0
},
"material": {
"coloration": {
"Color": {
"red": 0.2,
"green": 0.3,
"blue": 1.0
}
},
"albedo": 0.38,
"surface": "Diffuse"
}
}
}
],
"lights": [
{
"Spherical": {
"position": {
"x": -2.0,
"y": 10.0,
"z": -3.0
},
"color": {
"red": 0.3,
"green": 0.8,
"blue": 0.3
},
"intensity": 10000.0
}
},
{
"Spherical": {
"position": {
"x": 0.25,
"y": 0.0,
"z": -2.0
},
"color": {
"red": 0.8,
"green": 0.3,
"blue": 0.3
},
"intensity": 250.0
}
},
{
"Directional": {
"direction": {
"x": 0.0,
"y": 0.0,
"z": -1.0
},
"color": {
"red": 1.0,
"green": 1.0,
"blue": 1.0
},
"intensity": 0.0
}
}
]
}
</textarea>
<button disabled id='render'>Loading wasm...</button>
<div class='concurrency'>
<p id='concurrency-amt'>Concurrency: 1</p>
<br/>
<input disabled type="range" id="concurrency" min="0" max="1" />
</div>
<div id='timing'>
Render duration:
<p id='timing-val'></p>
</div>
<canvas id='canvas'></canvas>
<script>
document.getElementById('render').disabled = true;
document.getElementById('concurrency').disabled = true;
</script>
<script src='../pkg/raytrace_parallel.js'></script>
<script type="module" src="../js/index.js"></script>
</body>
</html>
- index.js
const button = document.getElementById('render');
const canvas = document.getElementById('canvas');
const scene = document.getElementById('scene');
const concurrency = document.getElementById('concurrency');
const concurrencyAmt = document.getElementById('concurrency-amt');
const timing = document.getElementById('timing');
const timingVal = document.getElementById('timing-val');
const ctx = canvas.getContext('2d');
button.disabled = true;
concurrency.disabled = true;
// First up, but try to do feature detection to provide better error messages
function loadWasm() {
let msg = 'This demo requires a current version of Firefox (e.g., 79.0)';
if (typeof SharedArrayBuffer !== 'function') {
alert('this browser does not have SharedArrayBuffer support enabled' + '\n\n' + msg);
return
}
// Test for bulk memory operations with passive data segments
// (module (memory 1) (data passive ""))
const buf = new Uint8Array([0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00,
0x05, 0x03, 0x01, 0x00, 0x01, 0x0b, 0x03, 0x01, 0x01, 0x00]);
if (!WebAssembly.validate(buf)) {
alert('this browser does not support passive wasm memory, demo does not work' + '\n\n' + msg);
return
}
wasm_bindgen()
.then(run)
.catch(console.error);
}
loadWasm();
const { Scene, WorkerPool } = wasm_bindgen;
function run() {
// The maximal concurrency of our web worker pool is `hardwareConcurrency`,
// so set that up here and this ideally is the only location we create web
// workers.
pool = new WorkerPool(navigator.hardwareConcurrency);
// Configure various buttons and such.
button.onclick = function() {
button.disabled = true;
console.time('render');
let json;
try {
json = JSON.parse(scene.value);
} catch(e) {
alert(`invalid json: ${e}`);
return
}
canvas.width = json.width;
canvas.height = json.height;
render(new Scene(json));
};
button.innerText = 'Render!';
button.disabled = false;
concurrency.oninput = function() {
concurrencyAmt.innerText = 'Concurrency: ' + concurrency.value;
};
concurrency.min = 1;
concurrency.step = 1;
concurrency.max = navigator.hardwareConcurrency;
concurrency.value = concurrency.max;
concurrency.oninput();
concurrency.disabled = false;
}
let rendering = null;
let start = null;
let interval = null;
let pool = null;
class State {
constructor(wasm) {
this.start = performance.now();
this.wasm = wasm;
this.running = true;
this.counter = 1;
this.interval = setInterval(() => this.updateTimer(true), 100);
wasm.promise()
.then(data => {
this.updateTimer(false);
this.updateImage(data);
this.stop();
})
.catch(console.error);
}
updateTimer(updateImage) {
const dur = performance.now() - this.start;
timingVal.innerText = `${dur}ms`;
this.counter += 1;
if (updateImage && this.wasm && this.counter % 3 == 0)
this.updateImage(this.wasm.imageSoFar());
}
updateImage(data) {
ctx.putImageData(data, 0, 0);
}
stop() {
if (!this.running)
return;
console.timeEnd('render');
this.running = false;
this.wasm = null;
clearInterval(this.interval);
button.disabled = false;
}
}
function render(scene) {
if (rendering) {
rendering.stop();
rendering = null;
}
rendering = new State(scene.render(parseInt(concurrency.value), pool));
}
- worker.js
// synchronously, using the browser, import out shim JS scripts
importScripts('pkg/raytrace_parallel.js');
// Wait for the main thread to send us the shared module/memory. Once we've got
// it, initialize it all with the `wasm_bindgen` global we imported via
// `importScripts`.
//
// After our first message all subsequent messages are an entry point to run,
// so we just do that.
self.onmessage = event => {
let initialised = wasm_bindgen(...event.data).catch(err => {
// Propagate to main `onerror`:
setTimeout(() => {
throw err;
});
// Rethrow to keep promise rejected and prevent execution of further commands:
throw err;
});
self.onmessage = async event => {
// This will queue further commands up until the module is fully initialised:
await initialised;
wasm_bindgen.child_entry_point(event.data);
};
};
- Rust side
#![allow(unused)] fn main() { // src/lib.rs use futures_channel::oneshot; use js_sys::{Promise, Uint8ClampedArray, WebAssembly}; use rayon::prelude::*; use wasm_bindgen::prelude::*; macro_rules! console_log { ($($t:tt)*) => (crate::log(&format_args!($($t)*).to_string())) } mod pool; #[wasm_bindgen] extern "C" { #[wasm_bindgen(js_namespace = console)] fn log(s: &str); #[wasm_bindgen(js_namespace = console, js_name = log)] fn logv(x: &JsValue); } #[wasm_bindgen] pub struct Scene { inner: raytracer::scene::Scene, } #[wasm_bindgen] impl Scene { /// Creates a new scene from the JSON description in `object`, which we /// deserialize here into an actual scene. #[wasm_bindgen(constructor)] pub fn new(object: JsValue) -> Result<Scene, JsValue> { console_error_panic_hook::set_once(); Ok(Scene { inner: serde_wasm_bindgen::from_value(object) .map_err(|e| JsValue::from(e.to_string()))?, }) } /// Renders this scene with the provided concurrency and worker pool. /// /// This will spawn up to `concurrency` workers which are loaded from or /// spawned into `pool`. The `RenderingScene` state contains information to /// get notifications when the render has completed. pub fn render( self, concurrency: usize, pool: &pool::WorkerPool, ) -> Result<RenderingScene, JsValue> { let scene = self.inner; let height = scene.height; let width = scene.width; // Allocate the pixel data which our threads will be writing into. let pixels = (width * height) as usize; let mut rgb_data = vec![0; 4 * pixels]; let base = rgb_data.as_ptr() as usize; let len = rgb_data.len(); // Configure a rayon thread pool which will pull web workers from // `pool`. let thread_pool = rayon::ThreadPoolBuilder::new() .num_threads(concurrency) .spawn_handler(|thread| { pool.run(|| thread.run()).unwrap(); Ok(()) }) .build() .unwrap(); // And now execute the render! The entire render happens on our worker // threads so we don't lock up the main thread, so we ship off a thread // which actually does the whole rayon business. When our returned // future is resolved we can pull out the final version of the image. let (tx, rx) = oneshot::channel(); pool.run(move || { thread_pool.install(|| { rgb_data .par_chunks_mut(4) .enumerate() .for_each(|(i, chunk)| { let i = i as u32; let x = i % width; let y = i / width; let ray = raytracer::Ray::create_prime(x, y, &scene); let result = raytracer::cast_ray(&scene, &ray, 0).to_rgba(); chunk[0] = result.data[0]; chunk[1] = result.data[1]; chunk[2] = result.data[2]; chunk[3] = result.data[3]; }); }); drop(tx.send(rgb_data)); })?; let done = async move { match rx.await { Ok(_data) => Ok(image_data(base, len, width, height).into()), Err(_) => Err(JsValue::undefined()), } }; Ok(RenderingScene { promise: wasm_bindgen_futures::future_to_promise(done), base, len, height, width, }) } } #[wasm_bindgen] pub struct RenderingScene { base: usize, len: usize, promise: Promise, width: u32, height: u32, } // Inline the definition of `ImageData` here because `web_sys` uses // `&Clamped<Vec<u8>>`, whereas we want to pass in a JS object here. #[wasm_bindgen] extern "C" { pub type ImageData; #[wasm_bindgen(constructor, catch)] fn new(data: &Uint8ClampedArray, width: f64, height: f64) -> Result<ImageData, JsValue>; } #[wasm_bindgen] impl RenderingScene { /// Returns the JS promise object which resolves when the render is complete pub fn promise(&self) -> Promise { self.promise.clone() } /// Return a progressive rendering of the image so far #[wasm_bindgen(js_name = imageSoFar)] pub fn image_so_far(&self) -> ImageData { image_data(self.base, self.len, self.width, self.height) } } fn image_data(base: usize, len: usize, width: u32, height: u32) -> ImageData { // Use the raw access available through `memory.buffer`, but be sure to // use `slice` instead of `subarray` to create a copy that isn't backed // by `SharedArrayBuffer`. Currently `ImageData` rejects a view of // `Uint8ClampedArray` that's backed by a shared buffer. // // FIXME: that this may or may not be UB based on Rust's rules. For example // threads may be doing unsynchronized writes to pixel data as we read it // off here. In the context of wasm this may or may not be UB, we're // unclear! In any case for now it seems to work and produces a nifty // progressive rendering. A more production-ready application may prefer to // instead use some form of signaling here to request an update from the // workers instead of synchronously acquiring an update, and that way we // could ensure that even on the Rust side of things it's not UB. let mem = wasm_bindgen::memory().unchecked_into::<WebAssembly::Memory>(); let mem = Uint8ClampedArray::new(&mem.buffer()).slice(base as u32, (base + len) as u32); ImageData::new(&mem, width as f64, height as f64).unwrap() } }
- pool.rs
#![allow(unused)] fn main() { // src/pool.rs // Silences warnings from the compiler about Work.func and child_entry_point // being unused when the target is not wasm. #![cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] //! A small module that's intended to provide an example of creating a pool of //! web workers which can be used to execute `rayon`-style work. use std::cell::RefCell; use std::rc::Rc; use wasm_bindgen::prelude::*; use web_sys::{DedicatedWorkerGlobalScope, MessageEvent}; use web_sys::{ErrorEvent, Event, Worker}; #[wasm_bindgen] pub struct WorkerPool { state: Rc<PoolState>, } struct PoolState { workers: RefCell<Vec<Worker>>, callback: Closure<dyn FnMut(Event)>, } struct Work { func: Box<dyn FnOnce() + Send>, } #[wasm_bindgen] impl WorkerPool { /// Creates a new `WorkerPool` which immediately creates `initial` workers. /// /// The pool created here can be used over a long period of time, and it /// will be initially primed with `initial` workers. Currently workers are /// never released or gc'd until the whole pool is destroyed. /// /// # Errors /// /// Returns any error that may happen while a JS web worker is created and a /// message is sent to it. #[wasm_bindgen(constructor)] pub fn new(initial: usize) -> Result<WorkerPool, JsValue> { let pool = WorkerPool { state: Rc::new(PoolState { workers: RefCell::new(Vec::with_capacity(initial)), callback: Closure::new(|event: Event| { console_log!("unhandled event: {}", event.type_()); crate::logv(&event); }), }), }; for _ in 0..initial { let worker = pool.spawn()?; pool.state.push(worker); } Ok(pool) } /// Unconditionally spawns a new worker /// /// The worker isn't registered with this `WorkerPool` but is capable of /// executing work for this wasm module. /// /// # Errors /// /// Returns any error that may happen while a JS web worker is created and a /// message is sent to it. fn spawn(&self) -> Result<Worker, JsValue> { console_log!("spawning new worker"); // TODO: what do do about `./worker.js`: // // * the path is only known by the bundler. How can we, as a // library, know what's going on? // * How do we not fetch a script N times? It internally then // causes another script to get fetched N times... let worker = Worker::new("./worker.js")?; // With a worker spun up send it the module/memory so it can start // instantiating the wasm module. Later it might receive further // messages about code to run on the wasm module. let array = js_sys::Array::new(); array.push(&wasm_bindgen::module()); array.push(&wasm_bindgen::memory()); worker.post_message(&array)?; Ok(worker) } /// Fetches a worker from this pool, spawning one if necessary. /// /// This will attempt to pull an already-spawned web worker from our cache /// if one is available, otherwise it will spawn a new worker and return the /// newly spawned worker. /// /// # Errors /// /// Returns any error that may happen while a JS web worker is created and a /// message is sent to it. fn worker(&self) -> Result<Worker, JsValue> { match self.state.workers.borrow_mut().pop() { Some(worker) => Ok(worker), None => self.spawn(), } } /// Executes the work `f` in a web worker, spawning a web worker if /// necessary. /// /// This will acquire a web worker and then send the closure `f` to the /// worker to execute. The worker won't be usable for anything else while /// `f` is executing, and no callbacks are registered for when the worker /// finishes. /// /// # Errors /// /// Returns any error that may happen while a JS web worker is created and a /// message is sent to it. fn execute(&self, f: impl FnOnce() + Send + 'static) -> Result<Worker, JsValue> { let worker = self.worker()?; let work = Box::new(Work { func: Box::new(f) }); let ptr = Box::into_raw(work); match worker.post_message(&JsValue::from(ptr as u32)) { Ok(()) => Ok(worker), Err(e) => { unsafe { drop(Box::from_raw(ptr)); } Err(e) } } } /// Configures an `onmessage` callback for the `worker` specified for the /// web worker to be reclaimed and re-inserted into this pool when a message /// is received. /// /// Currently this `WorkerPool` abstraction is intended to execute one-off /// style work where the work itself doesn't send any notifications and /// whatn it's done the worker is ready to execute more work. This method is /// used for all spawned workers to ensure that when the work is finished /// the worker is reclaimed back into this pool. fn reclaim_on_message(&self, worker: Worker) { let state = Rc::downgrade(&self.state); let worker2 = worker.clone(); let reclaim_slot = Rc::new(RefCell::new(None)); let slot2 = reclaim_slot.clone(); let reclaim = Closure::<dyn FnMut(_)>::new(move |event: Event| { if let Some(error) = event.dyn_ref::<ErrorEvent>() { console_log!("error in worker: {}", error.message()); // TODO: this probably leaks memory somehow? It's sort of // unclear what to do about errors in workers right now. return; } // If this is a completion event then can deallocate our own // callback by clearing out `slot2` which contains our own closure. if let Some(_msg) = event.dyn_ref::<MessageEvent>() { if let Some(state) = state.upgrade() { state.push(worker2.clone()); } *slot2.borrow_mut() = None; return; } console_log!("unhandled event: {}", event.type_()); crate::logv(&event); // TODO: like above, maybe a memory leak here? }); worker.set_onmessage(Some(reclaim.as_ref().unchecked_ref())); *reclaim_slot.borrow_mut() = Some(reclaim); } } impl WorkerPool { /// Executes `f` in a web worker. /// /// This pool manages a set of web workers to draw from, and `f` will be /// spawned quickly into one if the worker is idle. If no idle workers are /// available then a new web worker will be spawned. /// /// Once `f` returns the worker assigned to `f` is automatically reclaimed /// by this `WorkerPool`. This method provides no method of learning when /// `f` completes, and for that you'll need to use `run_notify`. /// /// # Errors /// /// If an error happens while spawning a web worker or sending a message to /// a web worker, that error is returned. pub fn run(&self, f: impl FnOnce() + Send + 'static) -> Result<(), JsValue> { let worker = self.execute(f)?; self.reclaim_on_message(worker); Ok(()) } } impl PoolState { fn push(&self, worker: Worker) { worker.set_onmessage(Some(self.callback.as_ref().unchecked_ref())); worker.set_onerror(Some(self.callback.as_ref().unchecked_ref())); let mut workers = self.workers.borrow_mut(); for prev in workers.iter() { let prev: &JsValue = prev; let worker: &JsValue = &worker; assert!(prev != worker); } workers.push(worker); } } /// Entry point invoked by `worker.js`, a bit of a hack but see the "TODO" above /// about `worker.js` in general. #[wasm_bindgen] pub fn child_entry_point(ptr: u32) -> Result<(), JsValue> { let ptr = unsafe { Box::from_raw(ptr as *mut Work) }; let global = js_sys::global().unchecked_into::<DedicatedWorkerGlobalScope>(); (ptr.func)(); global.post_message(&JsValue::undefined())?; Ok(()) } }
Running the demo
"Currently it's required to use the --target no-modules or --target web flag with wasm-bindgen to run threaded code. This is because the WebAssembly file imports memory instead of exporting it, so we need to hook initialization of the wasm module at this time to provide the appropriate memory object. This demo uses --target no-modules, because Firefox does not support modules in workers.
With --target no-modules you'll be able to use importScripts inside of each web worker to import the shim JS generated by wasm-bindgen as well as calling the wasm_bindgen initialization function with the shared memory instance from the main thread. The expected usage is that WebAssembly on the main thread will post its memory object to all other threads to get instantiated with."
_ wasm-bindgen Guide{target="_blank"}
build and serve
This example requires to not create ES modules, therefore we pass the flag
--target no-modules
wasm-pack build --target no-modules --no-typescript --out-dir www/pkg
http www
open index.html
firefox http://localhost:8000/html/
What's next?
Next example: Wasm audio worklet -->