Link Search Menu Expand Document

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.

image-20220321141006404

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:

  1. Telling the worker when to start (and what settings to use)
  2. Updating the canvas when the worker has rendered an element
  3. 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: