Title: Three.js OffscreenCanvas Description: How to use three.js in a web worker TOC: Using OffscreenCanvas in a Web Worker [`OffscreenCanvas`](https://developer.mozilla.org/en-US/docs/Web/API/OffscreenCanvas) is a relatively new browser feature currently only available in Chrome but apparently coming to other browsers. `OffscreenCanvas` allows a web worker to render to a canvas. This is a way to offload heavy work, like rendering a complex 3D scene, to a web worker so as not to slow down the responsiveness of the browser. It also means data is loaded and parsed in the worker so possibly less jank while the page loads. One big issue with offscreen canvas is, at least in Chrome, es6 modules are not yet supported on web workers, so, unlike all the other examples on this site that use es6 modules these examples will use class scripts. Getting *started* using it is pretty straight forward. Let's port the 3 spinning cube example from [the article on responsiveness](threejs-responsive.html). Workers generally have their code separated into another script file. Most of the examples on this site have had their scripts embedded into the HTML file of the page they are on. In our case we'll make a file called `offscreencanvas-cubes.js` and copy all the JavaScript from [the responsive example](threejs-responsive.html) into it. We'll then make the changes needed for it to run in a worker. We still need some JavaScript in our HTML file. The first thing we need to do there is look up the canvas and then transfer control of that canvas to be offscreen by calling `canvas.transferControlToOffscreen`. ```js function main() { const canvas = document.querySelector('#c'); const offscreen = canvas.transferControlToOffscreen(); ... ``` We can then start our worker with `new Worker(pathToScript)`. and pass the `offscreen` object to it. ```js function main() { const canvas = document.querySelector('#c'); const offscreen = canvas.transferControlToOffscreen(); const worker = new Worker('offscreencanvas-cubes.js'); worker.postMessage({type: 'main', canvas: offscreen}, [offscreen]); } main(); ``` It's important to note that workers can't access the `DOM`. They can't look at HTML elements nor can they receive mouse events or keyboard events. The only thing they can generally do is respond to messages sent to them. To send a message to a worker we call [`worker.postMessage`](https://developer.mozilla.org/en-US/docs/Web/API/Worker/postMessage) and pass it 1 or 2 arguments. The first argument is a JavaScript object that will be [cloned](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm) and sent to the worker. The second argument is an optional array of objects that are part of the first object that we want *transferred* to the worker. These objects will not be cloned. Instead they will be *transferred* and will cease to exist in the main page. Cease to exist is the probably the wrong description, rather they are neutered. Only certain types of objects can be transferred instead of cloned. They include `OffscreenCanvas` so once transferred the `offscreen` object back in the main page is useless. Workers receive messages from their `onmessage` handler. The object we passed to `postMessage` arrives on `event.data` passed to the `onmessage` handler on the worker. The code above declares a `type: 'main'` in the object it passes to the worker. We'll make a handler that based on `type` calls a different function in the worker. Then we can add functions as needed and easily call them from the main page. ```js const handlers = { main, }; self.onmessage = function(e) { const fn = handlers[e.data.type]; if (!fn) { throw new Error('no handler for type: ' + e.data.type); } fn(e.data); }; ``` You can see above we just look up the handler based on the `type` pass it the `data` that was sent from the main page. So now we just need to start changing the `main` we pasted into `offscreencanvas-cubes.js` from [the responsive article](threejs-responsive.html). The first thing we need to do is include THREE.js into our worker. ```js importScripts('resources/threejs/r114/build/three.min.js'); ``` Then instead of looking up the canvas from the DOM we'll receive it from the event data. ```js -function main() { - const canvas = document.querySelector('#c'); +function main(data) { + const {canvas} = data; const renderer = new THREE.WebGLRenderer({canvas}); ... ``` Remembering that workers can't see the DOM at all the first problem we run into is `resizeRendererToDisplaySize` can't look at `canvas.clientWidth` and `canvas.clientHeight` as those are DOM values. Here's the original code ```js function resizeRendererToDisplaySize(renderer) { const canvas = renderer.domElement; const width = canvas.clientWidth; const height = canvas.clientHeight; const needResize = canvas.width !== width || canvas.height !== height; if (needResize) { renderer.setSize(width, height, false); } return needResize; } ``` Instead we'll need to send sizes as they change to the worker. So, let's add some global state and keep the width and height there. ```js const state = { width: 300, // canvas default height: 150, // canvas default }; ``` Then let's add a `'size'` handler to update those values. ```js +function size(data) { + state.width = data.width; + state.height = data.height; +} const handlers = { main, + size, }; ``` Now we can change `resizeRendererToDisplaySize` to use `state.width` and `state.height` ```js function resizeRendererToDisplaySize(renderer) { const canvas = renderer.domElement; - const width = canvas.clientWidth; - const height = canvas.clientHeight; + const width = state.width; + const height = state.height; const needResize = canvas.width !== width || canvas.height !== height; if (needResize) { renderer.setSize(width, height, false); } return needResize; } ``` and where we compute the aspect we need similar changes ```js function render(time) { time *= 0.001; if (resizeRendererToDisplaySize(renderer)) { - camera.aspect = canvas.clientWidth / canvas.clientHeight; + camera.aspect = state.width / state.height; camera.updateProjectionMatrix(); } ... ``` Back in the main page we'll send a `size` event anytime the page changes size. ```js const worker = new Worker('offscreencanvas-picking.js'); worker.postMessage({type: 'main', canvas: offscreen}, [offscreen]); +function sendSize() { + worker.postMessage({ + type: 'size', + width: canvas.clientWidth, + height: canvas.clientHeight, + }); +} + +window.addEventListener('resize', sendSize); +sendSize(); ``` We also call it once to send the initial size. And with just those few changes, assuming your browser fully supports `OffscreenCanvas` it should work. Before we run it though let's check if the browser actually supports `OffscreenCanvas` and if not display an error. First let's add some HTML to display the error. ```html
+ ``` and some CSS for that ```css #noOffscreenCanvas { display: flex; width: 100vw; height: 100vh; align-items: center; justify-content: center; background: red; color: white; } ``` and then we can check for the existence of `transferControlToOffscreen` to see if the browser supports `OffscreenCanvas` ```js function main() { const canvas = document.querySelector('#c'); + if (!canvas.transferControlToOffscreen) { + canvas.style.display = 'none'; + document.querySelector('#noOffscreenCanvas').style.display = ''; + return; + } const offscreen = canvas.transferControlToOffscreen(); const worker = new Worker('offscreencanvas-picking.js'); worker.postMessage({type: 'main', canvas: offscreen}, [offscreen]); ... ``` and with that, if your browser supports `OffscreenCanvas` this example should work {{{example url="../threejs-offscreencanvas.html" }}} So that's great but since not every browser supports `OffscreenCanvas` at the moment let's change the code to work with both `OffscreenCanvas` and if not then fallback to using the canvas in the main page like normal. > As an aside, if you need OffscreenCanvas to make your page responsive then > it's not clear what the point of having a fallback is. Maybe based on if > you end up running on the main page or in a worker you might adjust the amount > of work done so that when running in a worker you can do more than when > running in the main page. What you do is really up to you. The first thing we should probably do is separate out the three.js code from the code that is specific to the worker. That we we can use the same code on both the main page and the worker. In other words we will now have 3 files 1. our html file. `threejs-offscreencanvas-w-fallback.html` 2. a JavaScript that contains our three.js code. `shared-cubes.js` 3. our worker support code `offscreencanvas-worker-cubes.js` `shared-cubes.js` and `offscreencanvas-worker-cubes.js` are basically the split of our previous `offscreencanvas-cubes.js` file. We renamed `main` to `init` since we already have a `main` in our HTML file. `offscreencanvas-worker-cubes.js` is just ```js 'use strict'; /* global importScripts, init, state */ importScripts('resources/threejs/r114/build/three.min.js'); +importScripts('shared-cubes.js'); function size(data) { state.width = data.width; state.height = data.height; } const handlers = { - main, + init, size, }; self.onmessage = function(e) { const fn = handlers[e.data.type]; if (!fn) { throw new Error('no handler for type: ' + e.data.type); } fn(e.data); }; ``` note we include `shared-cubes.js` which is all our three.js code Similarly we need to include three.js and `shared-cubes.js` in the main page ```html