10: Web Workers
- Download code
- 10-web-workers.zip
- Live example
- examples/10-web-workers/index.html
As our renderer gets more powerful, scenes will take more time to render – but because we’re hosting our renderer inside a web browser, which only has a single processing thread, this means our browser becomes unresponsive while a scene is rendering. It also means we have no way to stop a render once it’s started.
In this section, we’re going to use a technology called web workers to move our rendering into a background thread.
Browser support for worker modules
Because our renderer code uses JavaScript modules, adding web workers requires a browser that supports worker modules - and as of March 2022, worker modules do not work in Mozilla Firefox.
- Bugzilla issue 1247687 - worker module support in Firefox
- CanIUse.com Worker API support for ECMAScript modules
If you need to install a browser with support for worker modules, these examples have all been tested on:
Safari on macOS (and on iOS, if you want to run a ray-tracer on your iPhone!) has supported worker modules since version 15 (September 2021).
A simple worker module
Take a look at simple-worker.html for a very simple example of a web worker using ES modules, and how to use postMessage
and event listeners to communicate between the main browser thread and we workers.
Running a ray tracer as a worker module
To run our ray tracer in a worker module, we need to handle three things:
- Telling the worker when to start (and what settings to use)
- Updating the
canvas
when the worker has rendered an element - Stopping the worker if we want to cancel the render
There are a few constraints that make life interesting, though…
- Web workers can’t manipulate the DOM - they can’t see (or modify) any HTML elements, which means they can’t write to the
canvas
element. - The only way to communicate between the browser and the worker is using the
postMessage
API, and we can only send limited kinds of data via this API- Strings, numbers, booleans, and simple JavaScript objects are OK
- We can’t pass classes (or class instances), or references to any DOM elements.
So, here’s how we’re going to structure our code.
We’ll create a new module called worker.js
. This will create and start the actual trace, and will use postMessage
to send messages back to the main thread every time a pixel is rendered:
// worker.js
import { Renderer } from './modules/renderer.js';
import * as ExampleScenes from './scenes/examples.js';
function callback(x, y, color) {
let data = { x: x, y: y, r: color.r, g: color.g, b: color.b };
self.postMessage({ command: 'fillRect', ...data });
}
self.addEventListener('message', function (message) {
let data = message.data;
switch (data.command) {
case 'render':
let renderer = new Renderer(data.width, data.height);
let scene = ExampleScenes.AssortedShapes();
renderer.render(scene, callback);
self.close();
self.postMessage({ what: 'finished' });
break;
}
});
Next, we’ll modify main.js
to create a new web worker based on worker.js
, and start and stop it as required:
// main.js
let canvas = document.getElementById('my-canvas');
let ctx = canvas.getContext('2d');
let renderButton = document.getElementById('render-button');
let cancelButton = document.getElementById('cancel-button');
function paintPixel(x, y, color) {
var rgb = `rgb(${color.r},${color.g},${color.b})`;
ctx.fillStyle = rgb;
ctx.fillRect(x, y, 1, 1);
}
function handleMessageFromWorker(message) {
let data = message.data;
switch (data.command) {
case 'fillRect':
let color = { r: data.r, g: data.g, b: data.b };
paintPixel(data.x, data.y, color);
break;
case 'finished':
updateStatus(false);
break;
}
}
function render() {
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
let worker = new Worker('worker.js', { type: 'module' });
worker.addEventListener('message', handleMessageFromWorker);
cancelButton.addEventListener("click", function () {
worker.terminate();
updateStatus(false);
});
worker.postMessage({ command: 'render', width: canvas.width, height: canvas.height });
updateStatus(true);
};
function updateStatus(running) {
renderButton.disabled = running;
cancelButton.disabled = !running;
}
renderButton.addEventListener("click", render);
render();
Now, reload the page; you should see something like this:
The good news is that it’s definitely rendering on a background thread, and we can cancel the render without having to shut down our browser. The bad new is that our render time has gone from 0.8 seconds to nearly 30 seconds… which is terrible! This was supposed to be a performance optimisation, right?
But take a look in the browser’s console log:
Render completed in 0.734 seconds
That message is coming from the Renderer
itself… so what’s going on?
The answer is: we’re using the web worker API in a really inefficient way.
The renderer is taking 0.7 seconds to rip through the entire scene and render every pixel… and for every one of those pixels, it’s posting a message to the foreground thread saying “hey, I just rendered pixel (3,4) - it’s red!”
Internally, the browser pushes all those message into a queue, and then processes them as fast as it can. So after 0.7 seconds, our renderer has rendered the entire scene, and pushed a few hundred thousand messages onto the queue – one for every rendered pixel. The delay isn’t the renderer itself; we’re waiting for the browser to dequeue, unpack, and render several hundred thousand messages.
In the next module, we’ll look at a much more efficient way to pass rendered data from the worker back to the main browser thread.
- Download code
- 10-web-workers.zip
- Live example
- examples/10-web-workers/index.html