Browse Source

add offscreencanvas article

Gregg Tavares 6 years ago
parent
commit
5032300239

+ 1133 - 0
threejs/lessons/threejs-offscreencanvas.md

@@ -0,0 +1,1133 @@
+Title: Three.js OffscreenCanvas
+Description: How to use three.js 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.
+
+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 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)`.
+We then pass the `offscreen` object to the worker.
+
+```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('https://threejsfundamentals.org/threejs/resources/threejs/r103/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
+<body>
+  <canvas id="c"></canvas>
++  <div id="noOffscreenCanvas" style="display:none;">
++    <div>no OffscreenCanvas support</div>
++  </div>
+</body>
+```
+
+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 the 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/r103/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 `shared-cubes.js` in the main page
+
+```html
+<script src="resources/threejs/r103/three.min.js"></script>
++<script src="shared-cubes.js"></script>
+```
+We can remove the HTML and CSS we added previously
+
+```html
+<body>
+  <canvas id="c"></canvas>
+-  <div id="noOffscreenCanvas" style="display:none;">
+-    <div>no OffscreenCanvas support</div>
+-  </div>
+</body>
+```
+
+and some CSS for that
+
+```css
+-#noOffscreenCanvas {
+-    display: flex;
+-    width: 100vw;
+-    height: 100vh;
+-    align-items: center;
+-    justify-content: center;
+-    background: red;
+-    color: white;
+-}
+```
+
+Then let's change the code in the main page to call one start
+function or another depending on 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]);
++  if (canvas.transferControlToOffscreen) {
++    startWorker(canvas);
++  } else {
++    startMainPage(canvas);
++  }
+  ...
+```
+
+We'll move all the code we had to setup the worker inside `startWorker`
+
+```js
+function startWorker(canvas) {
+  const offscreen = canvas.transferControlToOffscreen();
+  const worker = new Worker('offscreencanvas-worker-cubes.js');
+  worker.postMessage({type: 'init', canvas: offscreen}, [offscreen]);
+
+  function sendSize() {
+    worker.postMessage({
+      type: 'size',
+      width: canvas.clientWidth,
+      height: canvas.clientHeight,
+    });
+  }
+
+  window.addEventListener('resize', sendSize);
+  sendSize();
+
+  console.log('using OffscreenCanvas');
+}
+```
+
+for starting in the main page we can do this
+
+```js
+function startMainPage(canvas) {
+  init({canvas});
+
+  function sendSize() {
+    state.width = canvas.clientWidth;
+    state.height = canvas.clientHeight;
+  }
+  window.addEventListener('resize', sendSize);
+  sendSize();
+
+  console.log('using regular canvas');
+}
+```
+
+and with that our example will run either in an OffscreenCanvas or
+fallback to running in the main page.
+
+{{{example url="../threejs-offscreencanvas-w-fallback.html" }}}
+
+So that was relatively easy. Let's try picking. We'll take some code from
+the `RayCaster` example from [the article on picking](threejs-picking.html)
+and make it work offscreen.
+
+Let's copy the `shared-cube.js` to `shared-picking.js` and add the
+picking parts. We copy in the `PickHelper` 
+
+```js
+class PickHelper {
+  constructor() {
+    this.raycaster = new THREE.Raycaster();
+    this.pickedObject = null;
+    this.pickedObjectSavedColor = 0;
+  }
+  pick(normalizedPosition, scene, camera, time) {
+    // restore the color if there is a picked object
+    if (this.pickedObject) {
+      this.pickedObject.material.emissive.setHex(this.pickedObjectSavedColor);
+      this.pickedObject = undefined;
+    }
+
+    // cast a ray through the frustum
+    this.raycaster.setFromCamera(normalizedPosition, camera);
+    // get the list of objects the ray intersected
+    const intersectedObjects = this.raycaster.intersectObjects(scene.children);
+    if (intersectedObjects.length) {
+      // pick the first object. It's the closest one
+      this.pickedObject = intersectedObjects[0].object;
+      // save its color
+      this.pickedObjectSavedColor = this.pickedObject.material.emissive.getHex();
+      // set its emissive color to flashing red/yellow
+      this.pickedObject.material.emissive.setHex((time * 8) % 2 > 1 ? 0xFFFF00 : 0xFF0000);
+    }
+  }
+}
+
+const pickPosition = {x: 0, y: 0};
+const pickHelper = new PickHelper();
+```
+
+We updated `pickPosition` from the mouse like this
+
+```js
+function setPickPosition(event) {
+  pickPosition.x = (event.clientX / canvas.clientWidth ) *  2 - 1;
+  pickPosition.y = (event.clientY / canvas.clientHeight) * -2 + 1;  // note we flip Y
+}
+window.addEventListener('mousemove', setPickPosition);
+```
+
+A worker can't read the mouse position directly so just like the size code
+let's send a message with the mouse position. Like the size code we'll
+send the mouse position and update `pickPosition`
+
+```js
+function size(data) {
+  state.width = data.width;
+  state.height = data.height;
+}
+
++function mouse(data) {
++  pickPosition.x = data.x;
++  pickPosition.y = data.y;
++}
+
+const handlers = {
+  init,
++  mouse,
+  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);
+};
+```
+
+Back in our main page we need to add code to pass the mouse
+to the worker or the main page.
+
+```js
++let sendMouse;
+
+function startWorker(canvas) {
+  const offscreen = canvas.transferControlToOffscreen();
+  const worker = new Worker('offscreencanvas-worker-picking.js');
+  worker.postMessage({type: 'init', canvas: offscreen}, [offscreen]);
+
++  sendMouse = (x, y) => {
++    worker.postMessage({
++      type: 'mouse',
++      x,
++      y,
++    });
++  };
+
+  function sendSize() {
+    worker.postMessage({
+      type: 'size',
+      width: canvas.clientWidth,
+      height: canvas.clientHeight,
+    });
+  }
+
+  window.addEventListener('resize', sendSize);
+  sendSize();
+
+  console.log('using OffscreenCanvas');  /* eslint-disable-line no-console */
+}
+
+function startMainPage(canvas) {
+  init({canvas});
+
++  sendMouse = (x, y) => {
++    pickPosition.x = x;
++    pickPosition.y = y;
++  };
+
+  function sendSize() {
+    state.width = canvas.clientWidth;
+    state.height = canvas.clientHeight;
+  }
+  window.addEventListener('resize', sendSize);
+  sendSize();
+
+  console.log('using regular canvas');  /* eslint-disable-line no-console */
+}
+
+```
+
+Then we can copy in all the mouse handling code to the main page and 
+make just minor changes to use `sendMouse`
+
+```js
+function setPickPosition(event) {
+-  pickPosition.x = (event.clientX / canvas.clientWidth ) *  2 - 1;
+-  pickPosition.y = (event.clientY / canvas.clientHeight) * -2 + 1;  // note we flip Y
++  sendMouse(
++      (event.clientX / canvas.clientWidth ) *  2 - 1,
++      (event.clientY / canvas.clientHeight) * -2 + 1);  // note we flip Y
+}
+
+function clearPickPosition() {
+  // unlike the mouse which always has a position
+  // if the user stops touching the screen we want
+  // to stop picking. For now we just pick a value
+  // unlikely to pick something
+-  pickPosition.x = -100000;
+-  pickPosition.y = -100000;
++  sendMouse(-100000, -100000);
+}
+window.addEventListener('mousemove', setPickPosition);
+window.addEventListener('mouseout', clearPickPosition);
+window.addEventListener('mouseleave', clearPickPosition);
+
+window.addEventListener('touchstart', (event) => {
+  // prevent the window from scrolling
+  event.preventDefault();
+  setPickPosition(event.touches[0]);
+}, {passive: false});
+
+window.addEventListener('touchmove', (event) => {
+  setPickPosition(event.touches[0]);
+});
+
+window.addEventListener('touchend', clearPickPosition);
+```
+
+and with that picking should be working with `OffscreenCanvas`.
+
+{{{example url="../threejs-offscreencanvas-w-picking.html" }}}
+
+Let's take it one more step and add in the `OrbitControls`.
+This will be little more involved. The `OrbitControls` use
+the DOM pretty extensively checking the mouse, touch events,
+and the keyboard.
+
+Unlike our code so far we can't really use a global `state` object
+without re-writing all the OrbitControls code to work with it.
+The OrbitControls take an element to which they attach most
+of the DOM events they use. Maybe we could pass in our own
+object that has the same API surface as a DOM element. 
+We only need to support the features the OrbitControls need.
+
+Digging through the [OrbitControls source code](https://github.com/greggman/threejsfundamentals/blob/master/threejs/resources/threejs/r103/js/controls/OrbitControls.js)
+it looks like we need to handle the following events.
+
+* contextmenu
+* mousedown
+* mousemove
+* mouseup
+* touchstart
+* touchmove
+* touchend
+* wheel
+* keydown
+
+For the mouse events we need the `ctrlKey`, `metaKey`, `shiftKey`, 
+`button`, `clientX`, `clientY`, `pageX`, and `pageY`, properties
+
+For the keydown events we need the `ctrlKey`, `metaKey`, `shiftKey`, 
+and `keyCode` properties.
+
+For the wheel event we only need the `deltaY` property
+
+And for the touch events we only need `pageX` and `pageY` from
+the `touches` property.
+
+So, let's make a proxy object pair. One part will run in the main page,
+get all those events, and pass on the relevant property values
+to the worker. The other part will run in the worker, receive those
+events and pass them on using events that have the same structure
+as the original DOM events so the OrbitControls won't be able to
+tell the difference.
+
+Here's the code for the worker part.
+
+```js
+class ElementProxyReceiver extends THREE.EventDispatcher {
+  constructor() {
+    super();
+  }
+  handleEvent(data) {
+    this.dispatchEvent(data);
+  }
+}
+```
+
+All it does is if it receives a message it dispatches it.
+It inherits from `EventDispatcher` which provides methods like
+`addEventListener` and `removeEventListener` just like a DOM
+element so if we pass it to the OrbitControls it should work.
+
+`ElementProxyReceiver` handles 1 element. In our case we only need
+one but it's best to think head so lets make a manager to manage
+more than one of them.
+
+```js
+class ProxyManager {
+  constructor() {
+    this.targets = {};
+    this.handleEvent = this.handleEvent.bind(this);
+  }
+  makeProxy(data) {
+    const {id} = data;
+    const proxy = new ElementProxyReceiver();
+    this.targets[id] = proxy;
+  }
+  getProxy(id) {
+    return this.targets[id];
+  }
+  handleEvent(data) {
+    this.targets[data.id].handleEvent(data.data);
+  }
+}
+```
+
+We can make a instance of `ProxyManager` and call its `makeProxy`
+method with an id which will make an `ElementProxyReceiver` that
+responds to messages with that id.
+
+Let's hook it up to our worker's message handler.
+
+```js
+const proxyManager = new ProxyManager();
+
+function start(data) {
+  const proxy = proxyManager.getProxy(data.canvasId);
+  init({
+    canvas: data.canvas,
+    inputElement: proxy,
+  });
+}
+
+function makeProxy(data) {
+  proxyManager.makeProxy(data);
+}
+
+...
+
+const handlers = {
+-  init,
+-  mouse,
++  start,
++  makeProxy,
++  event: proxyManager.handleEvent,
+   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);
+};
+```
+
+We also need to actually add the `OrbitControls` to the top of
+the script
+
+```js
+importScripts('resources/threejs/r103/three.js');
++importScripts('resources/threejs/r103/js/controls/OrbitControls.js');
+*importScripts('shared-orbitcontrols.js');
+```
+
+and in our shared three.js code we need to set them up
+
+```js
+function init(data) {
+-  const {canvas} = data;
++  const {canvas, inputElement} = data;
+  const renderer = new THREE.WebGLRenderer({canvas});
+
++  const controls = new THREE.OrbitControls(camera, inputElement);
++  controls.target.set(0, 0, 0);
++  controls.update();
+```
+
+Notice we're passing the OrbitControls our proxy via `inputElement`
+instead of passing in the canvas like we do in other non-OffscreenCanvas
+examples.
+
+Next we can move all the picking event code from the HTML file
+to the shared three.js code as well while changing
+`canvas` to `inputElement`.
+
+```js
+function setPickPosition(event) {
+-  sendMouse(
+-      (event.clientX / canvas.clientWidth ) *  2 - 1,
+-      (event.clientY / canvas.clientHeight) * -2 + 1);  // note we flip Y
++  pickPosition.x = (event.clientX / inputElement.clientWidth ) *  2 - 1;
++  pickPosition.y = (event.clientY / inputElement.clientHeight) * -2 + 1;  // note we flip Y
+}
+
+function clearPickPosition() {
+  // unlike the mouse which always has a position
+  // if the user stops touching the screen we want
+  // to stop picking. For now we just pick a value
+  // unlikely to pick something
+-  sendMouse(-100000, -100000);
++  pickPosition.x = -100000;
++  pickPosition.y = -100000;
+}
+
+*inputElement.addEventListener('mousemove', setPickPosition);
+*inputElement.addEventListener('mouseout', clearPickPosition);
+*inputElement.addEventListener('mouseleave', clearPickPosition);
+
+*inputElement.addEventListener('touchstart', (event) => {
+  // prevent the window from scrolling
+  event.preventDefault();
+  setPickPosition(event.touches[0]);
+}, {passive: false});
+
+*inputElement.addEventListener('touchmove', (event) => {
+  setPickPosition(event.touches[0]);
+});
+
+*inputElement.addEventListener('touchend', clearPickPosition);
+```
+
+Back in the main page we need code to send messages for
+all the events we enumerated above.
+
+```js
+let nextProxyId = 0;
+class ElementProxy {
+  constructor(element, worker, eventHandlers) {
+    this.id = nextProxyId++;
+    this.worker = worker;
+    const sendEvent = (data) => {
+      this.worker.postMessage({
+        type: 'event',
+        id: this.id,
+        data,
+      });
+    };
+
+    // register an id
+    worker.postMessage({
+      type: 'makeProxy',
+      id: this.id,
+    });
+    for (const [eventName, handler] of Object.entries(eventHandlers)) {
+      element.addEventListener(eventName, function(event) {
+        handler(event, sendEvent);
+      });
+    }
+  }
+}
+```
+
+`ElementProxy` takes the element who's events we want to proxy. It
+then registers an id with the worker by picking one and sending it
+via the `makeProxy` message we setup earlier. The worker will make
+an `ElementProxyReceiver` and register it to that id.
+
+We then have an object of event handlers to register. This way
+we can pass handlers only for these events we want to forward to
+the worker.
+
+When we start the worker we first make a proxy and pass in our event handlers.
+
+```js
+function startWorker(canvas) {
+  const offscreen = canvas.transferControlToOffscreen();
+  const worker = new Worker('offscreencanvas-worker-orbitcontrols.js');
+
++  const eventHandlers = {
++    contextmenu: preventDefaultHandler,
++    mousedown: mouseEventHandler,
++    mousemove: mouseEventHandler,
++    mouseup: mouseEventHandler,
++    touchstart: touchEventHandler,
++    touchmove: touchEventHandler,
++    touchend: touchEventHandler,
++    wheel: wheelEventHandler,
++    keydown: filteredKeydownEventHandler,
++  };
++  const proxy = new ElementProxy(canvas, worker, eventHandlers);
+  worker.postMessage({
+    type: 'start',
+    canvas: offscreen,
++    canvasId: proxy.id,
+  }, [offscreen]);
+  console.log('using OffscreenCanvas');  /* eslint-disable-line no-console */
+}
+```
+
+And here are the event handlers. All they do is copy a list of properties
+from the event they receive. They are passed a `sendEvent` function to which they pass the data
+they make. That function will add the correct id and send it to the worker.
+
+```js
+const mouseEventHandler = makeSendPropertiesHandler([
+  'ctrlKey',
+  'metaKey',
+  'shiftKey',
+  'button',
+  'clientX',
+  'clientY',
+  'pageX',
+  'pageY',
+]);
+const wheelEventHandlerImpl = makeSendPropertiesHandler([
+  'deltaX',
+  'deltaY',
+]);
+const keydownEventHandler = makeSendPropertiesHandler([
+  'ctrlKey',
+  'metaKey',
+  'shiftKey',
+  'keyCode',
+]);
+
+function wheelEventHandler(event, sendFn) {
+  event.preventDefault();
+  wheelEventHandlerImpl(event, sendFn);
+}
+
+function preventDefaultHandler(event) {
+  event.preventDefault();
+}
+
+function copyProperties(src, properties, dst) {
+  for (const name of properties) {
+      dst[name] = src[name];
+  }
+}
+
+function makeSendPropertiesHandler(properties) {
+  return function sendProperties(event, sendFn) {
+    const data = {type: event.type};
+    copyProperties(event, properties, data);
+    sendFn(data);
+  };
+}
+
+function touchEventHandler(event, sendFn) {
+  const touches = [];
+  const data = {type: event.type, touches};
+  for (let i = 0; i < event.touches.length; ++i) {
+    const touch = event.touches[i];
+    touches.push({
+      pageX: touch.pageX,
+      pageY: touch.pageY,
+    });
+  }
+  sendFn(data);
+}
+
+// The four arrow keys
+const orbitKeys = {
+  '37': true,  // left
+  '38': true,  // up
+  '39': true,  // right
+  '40': true,  // down
+};
+function filteredKeydownEventHandler(event, sendFn) {
+  const {keyCode} = event;
+  if (orbitKeys[keyCode]) {
+    event.preventDefault();
+    keydownEventHandler(event, sendFn);
+  }
+}
+```
+
+This seems close to running but if we actually try it we'll see
+that the `OrbitControls` need a few more things.
+
+One is they call `element.focus`. We don't need that to happen
+in the worker so let's just add a stub.
+
+```js
+class ElementProxyReceiver extends THREE.EventDispatcher {
+  constructor() {
+    super();
+  }
+  handleEvent(data) {
+    this.dispatchEvent(data);
+  }
++  focus() {
++    // no-op
++  }
+}
+```
+
+Another is they call `event.preventDefault` and `event.stopPropagation`.
+We're already handling that in the main page so those can also be a noop.
+
+```js
++function noop() {
++}
+
+class ElementProxyReceiver extends THREE.EventDispatcher {
+  constructor() {
+    super();
+  }
+  handleEvent(data) {
++    data.preventDefault = noop;
++    data.stopPropagation = noop;
+    this.dispatchEvent(data);
+  }
+  focus() {
+    // no-op
+  }
+}
+```
+
+Another is they look at `clientWidth` and `clientHeight`. We
+were passing the size before but we can update the proxy pair
+to pass that as well.
+
+In the worker...
+
+```js
+class ElementProxyReceiver extends THREE.EventDispatcher {
+  constructor() {
+    super();
+  }
++  get clientWidth() {
++    return this.width;
++  }
++  get clientHeight() {
++    return this.height;
++  }
+  handleEvent(data) {
++    if (data.type === 'size') {
++      this.width = data.width;
++      this.height = data.height;
++      return;
++    }
+    data.preventDefault = noop;
+    data.stopPropagation = noop;
+    this.dispatchEvent(data);
+  }
+  focus() {
+    // no-op
+  }
+}
+```
+
+back in the main page we need to send the size
+
+```js
+class ElementProxy {
+  constructor(element, worker, eventHandlers) {
+    this.id = nextProxyId++;
+    this.worker = worker;
+    const sendEvent = (data) => {
+      this.worker.postMessage({
+        type: 'event',
+        id: this.id,
+        data,
+      });
+    };
+
+    // register an id
+    worker.postMessage({
+      type: 'makeProxy',
+      id: this.id,
+    });
++    sendSize();
+    for (const [eventName, handler] of Object.entries(eventHandlers)) {
+      element.addEventListener(eventName, function(event) {
+        handler(event, sendEvent);
+      });
+    }
+
++    function sendSize() {
++      sendEvent({
++        type: 'size',
++        width: element.clientWidth,
++        height: element.clientHeight,
++      });
++    }
++
++    window.addEventListener('resize', sendSize);
+  }
+}
+```
+
+and in our shared three.js code we no longer need `state`
+
+```js
+-const state = {
+-  width: 300,   // canvas default
+-  height: 150,  // canvas default
+-};
+
+...
+
+function resizeRendererToDisplaySize(renderer) {
+  const canvas = renderer.domElement;
+-  const width = state.width;
+-  const height = state.height;
++  const width = inputElement.clientWidth;
++  const height = inputElement.clientHeight;
+  const needResize = canvas.width !== width || canvas.height !== height;
+  if (needResize) {
+    renderer.setSize(width, height, false);
+  }
+  return needResize;
+}
+
+function render(time) {
+  time *= 0.001;
+
+  if (resizeRendererToDisplaySize(renderer)) {
+-    camera.aspect = state.width / state.height;
++    camera.aspect = inputElement.clientWidth / inputElement.clientHeight;
+    camera.updateProjectionMatrix();
+  }
+
+  ...
+```
+
+A few more hacks. The OrbitControls attach event handlers to `window` to
+get keyboard events. Not sure that's the right place to attach them but that's beyond our control
+unless we fork the OrbitControls. In any case `window` doesn't exist in a worker.
+
+They also also add `mousemove` and `mouseup` events to the `document`
+to handle mouse capture (when the mouse goes outside the window) but
+like `window` there is no `document` in a worker.
+
+Finally they look at `document.body`.
+
+We can solve all of these with a few quick hacks. In our worker
+code we'll re-use our proxy for all 3 problems.
+
+```js
+function start(data) {
+  const proxy = proxyManager.getProxy(data.canvasId);
++  proxy.body = proxy;  // HACK!
++  self.window = proxy;  // HACK!
++  self.document = proxy;  // HACK!
+  init({
+    canvas: data.canvas,
+    inputElement: proxy,
+  });
+}
+```
+
+This will give the `OrbitControls` something to inspect which
+matches their expectations.
+
+I know that was kind of hard to follow. The short version is:
+`ElementProxy` runs on the main page and forwards DOM events 
+to `ElementProxyReceiver` in the worker which
+masquerades as an `HTMLElement` that we can use both with the
+`OrbitControls` and with our own.
+
+The final thing is our fallback when we are not using OffscreenCanvas.
+All we have to do is pass the canvas itself as our `inputElement`.
+
+```js
+function startMainPage(canvas) {
+-  init({canvas});
++  init({canvas, inputElement: canvas});
+  console.log('using regular canvas');
+}
+```
+
+and now we should have OrbitControls working with OffscreenCanvas
+
+{{{example url="../threejs-offscreencanvas-w-orbitcontrols.html" }}}
+
+This is probably the most complicated example on this site. It's a
+little hard to follow because there are 3 files involved for each
+sample. The HTML file, the worker file, the shared three.js code.
+
+I hope it wasn't too difficult to understand and that it provided some 
+useful examples of working with three.js, OffscreenCanvas and web workers.

+ 1 - 0
threejs/lessons/toc.html

@@ -31,6 +31,7 @@
   <ul>
     <li><a href="/threejs/lessons/threejs-optimize-lots-of-objects.html">Optimizing Lots of Objects</a></li>
     <li><a href="/threejs/lessons/threejs-optimize-lots-of-objects-animated.html">Optimizing Lots of Objects Animated</a></li>
+    <li><a href="/threejs/lessons/threejs-offscreencanvas.html">Using OffscreenCanvas in a Web Worker</a></li>
   </ul>
   <li>Tips</li>
   <ul>

+ 110 - 0
threejs/offscreencanvas-cubes.js

@@ -0,0 +1,110 @@
+'use strict';
+
+/* global importScripts, THREE */
+
+importScripts('resources/threejs/r103/three.min.js');
+
+const state = {
+  width: 300,   // canvas default
+  height: 150,  // canvas default
+};
+
+function main(data) {
+  const {canvas} = data;
+  const renderer = new THREE.WebGLRenderer({canvas});
+
+  state.width = canvas.width;
+  state.height = canvas.height;
+
+  const fov = 75;
+  const aspect = 2; // the canvas default
+  const near = 0.1;
+  const far = 100;
+  const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
+  camera.position.z = 4;
+
+  const scene = new THREE.Scene();
+
+  {
+    const color = 0xFFFFFF;
+    const intensity = 1;
+    const light = new THREE.DirectionalLight(color, intensity);
+    light.position.set(-1, 2, 4);
+    scene.add(light);
+  }
+
+  const boxWidth = 1;
+  const boxHeight = 1;
+  const boxDepth = 1;
+  const geometry = new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth);
+
+  function makeInstance(geometry, color, x) {
+    const material = new THREE.MeshPhongMaterial({
+      color,
+    });
+
+    const cube = new THREE.Mesh(geometry, material);
+    scene.add(cube);
+
+    cube.position.x = x;
+
+    return cube;
+  }
+
+  const cubes = [
+    makeInstance(geometry, 0x44aa88, 0),
+    makeInstance(geometry, 0x8844aa, -2),
+    makeInstance(geometry, 0xaa8844, 2),
+  ];
+
+  function resizeRendererToDisplaySize(renderer) {
+    const canvas = renderer.domElement;
+    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;
+  }
+
+  function render(time) {
+    time *= 0.001;
+
+    if (resizeRendererToDisplaySize(renderer)) {
+      camera.aspect = state.width / state.height;
+      camera.updateProjectionMatrix();
+    }
+
+    cubes.forEach((cube, ndx) => {
+      const speed = 1 + ndx * .1;
+      const rot = time * speed;
+      cube.rotation.x = rot;
+      cube.rotation.y = rot;
+    });
+
+    renderer.render(scene, camera);
+
+    requestAnimationFrame(render);
+  }
+
+  requestAnimationFrame(render);
+}
+
+function size(data) {
+  state.width = data.width;
+  state.height = data.height;
+}
+
+const handlers = {
+  main,
+  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);
+};

+ 24 - 0
threejs/offscreencanvas-worker-cubes.js

@@ -0,0 +1,24 @@
+'use strict';
+
+/* global importScripts, init, state */
+
+importScripts('resources/threejs/r103/three.min.js');
+importScripts('shared-cubes.js');
+
+function size(data) {
+  state.width = data.width;
+  state.height = data.height;
+}
+
+const handlers = {
+  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);
+};

+ 84 - 0
threejs/offscreencanvas-worker-orbitcontrols.js

@@ -0,0 +1,84 @@
+'use strict';
+
+/* global importScripts, init, THREE */
+
+importScripts('resources/threejs/r103/three.js');
+importScripts('resources/threejs/r103/js/controls/OrbitControls.js');
+importScripts('shared-orbitcontrols.js');
+
+function noop() {
+}
+
+class ElementProxyReceiver extends THREE.EventDispatcher {
+  constructor() {
+    super();
+  }
+  get clientWidth() {
+    return this.width;
+  }
+  get clientHeight() {
+    return this.height;
+  }
+  handleEvent(data) {
+    if (data.type === 'size') {
+      this.width = data.width;
+      this.height = data.height;
+      return;
+    }
+    data.preventDefault = noop;
+    data.stopPropagation = noop;
+    this.dispatchEvent(data);
+  }
+  focus() {
+    // no-op
+  }
+}
+
+class ProxyManager {
+  constructor() {
+    this.targets = {};
+    this.handleEvent = this.handleEvent.bind(this);
+  }
+  makeProxy(data) {
+    const {id} = data;
+    const proxy = new ElementProxyReceiver();
+    this.targets[id] = proxy;
+  }
+  getProxy(id) {
+    return this.targets[id];
+  }
+  handleEvent(data) {
+    this.targets[data.id].handleEvent(data.data);
+  }
+}
+
+const proxyManager = new ProxyManager();
+
+function start(data) {
+  const proxy = proxyManager.getProxy(data.canvasId);
+  proxy.body = proxy;  // HACK!
+  self.window = proxy;
+  self.document = proxy;
+  init({
+    canvas: data.canvas,
+    inputElement: proxy,
+  });
+}
+
+function makeProxy(data) {
+  proxyManager.makeProxy(data);
+}
+
+const handlers = {
+  start,
+  makeProxy,
+  event: proxyManager.handleEvent,
+};
+
+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);
+};

+ 30 - 0
threejs/offscreencanvas-worker-picking.js

@@ -0,0 +1,30 @@
+'use strict';
+
+/* global importScripts, init, state, pickPosition */
+
+importScripts('resources/threejs/r103/three.min.js');
+importScripts('shared-picking.js');
+
+function size(data) {
+  state.width = data.width;
+  state.height = data.height;
+}
+
+function mouse(data) {
+  pickPosition.x = data.x;
+  pickPosition.y = data.y;
+}
+
+const handlers = {
+  init,
+  mouse,
+  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);
+};

+ 1 - 4
threejs/resources/lessons-helper.js

@@ -370,15 +370,12 @@
    * Gets a WebGL context.
    * makes its backing store the size it is displayed.
    * @param {HTMLCanvasElement} canvas a canvas element.
-   * @param {module:webgl-utils.GetWebGLContextOptions} [opt_options] options
    * @memberOf module:webgl-utils
    */
-  let setupLesson = function(canvas, opt_options) {
+  let setupLesson = function(canvas) {
     // only once
     setupLesson = function() {};
 
-    const options = opt_options || {};
-
     if (canvas) {
       canvas.addEventListener('webglcontextlost', function(e) {
           // the default is to do nothing. Preventing the default

+ 91 - 0
threejs/shared-cubes.js

@@ -0,0 +1,91 @@
+'use strict';
+
+/* global THREE */
+
+const state = {
+  width: 300,   // canvas default
+  height: 150,  // canvas default
+};
+
+function init(data) {  /* eslint-disable-line no-unused-vars */
+  const {canvas} = data;
+  const renderer = new THREE.WebGLRenderer({canvas});
+
+  state.width = canvas.width;
+  state.height = canvas.height;
+
+  const fov = 75;
+  const aspect = 2; // the canvas default
+  const near = 0.1;
+  const far = 100;
+  const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
+  camera.position.z = 4;
+
+  const scene = new THREE.Scene();
+
+  {
+    const color = 0xFFFFFF;
+    const intensity = 1;
+    const light = new THREE.DirectionalLight(color, intensity);
+    light.position.set(-1, 2, 4);
+    scene.add(light);
+  }
+
+  const boxWidth = 1;
+  const boxHeight = 1;
+  const boxDepth = 1;
+  const geometry = new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth);
+
+  function makeInstance(geometry, color, x) {
+    const material = new THREE.MeshPhongMaterial({
+      color,
+    });
+
+    const cube = new THREE.Mesh(geometry, material);
+    scene.add(cube);
+
+    cube.position.x = x;
+
+    return cube;
+  }
+
+  const cubes = [
+    makeInstance(geometry, 0x44aa88, 0),
+    makeInstance(geometry, 0x8844aa, -2),
+    makeInstance(geometry, 0xaa8844, 2),
+  ];
+
+  function resizeRendererToDisplaySize(renderer) {
+    const canvas = renderer.domElement;
+    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;
+  }
+
+  function render(time) {
+    time *= 0.001;
+
+    if (resizeRendererToDisplaySize(renderer)) {
+      camera.aspect = state.width / state.height;
+      camera.updateProjectionMatrix();
+    }
+
+    cubes.forEach((cube, ndx) => {
+      const speed = 1 + ndx * .1;
+      const rot = time * speed;
+      cube.rotation.x = rot;
+      cube.rotation.y = rot;
+    });
+
+    renderer.render(scene, camera);
+
+    requestAnimationFrame(render);
+  }
+
+  requestAnimationFrame(render);
+}
+

+ 151 - 0
threejs/shared-orbitcontrols.js

@@ -0,0 +1,151 @@
+'use strict';
+
+/* global THREE */
+
+function init(data) {   /* eslint-disable-line no-unused-vars */
+  const {canvas, inputElement} = data;
+  const renderer = new THREE.WebGLRenderer({canvas});
+
+  const fov = 75;
+  const aspect = 2; // the canvas default
+  const near = 0.1;
+  const far = 100;
+  const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
+  camera.position.z = 4;
+
+  const controls = new THREE.OrbitControls(camera, inputElement);
+  controls.target.set(0, 0, 0);
+  controls.update();
+
+  const scene = new THREE.Scene();
+
+  {
+    const color = 0xFFFFFF;
+    const intensity = 1;
+    const light = new THREE.DirectionalLight(color, intensity);
+    light.position.set(-1, 2, 4);
+    scene.add(light);
+  }
+
+  const boxWidth = 1;
+  const boxHeight = 1;
+  const boxDepth = 1;
+  const geometry = new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth);
+
+  function makeInstance(geometry, color, x) {
+    const material = new THREE.MeshPhongMaterial({
+      color,
+    });
+
+    const cube = new THREE.Mesh(geometry, material);
+    scene.add(cube);
+
+    cube.position.x = x;
+
+    return cube;
+  }
+
+  const cubes = [
+    makeInstance(geometry, 0x44aa88, 0),
+    makeInstance(geometry, 0x8844aa, -2),
+    makeInstance(geometry, 0xaa8844, 2),
+  ];
+
+  class PickHelper {
+    constructor() {
+      this.raycaster = new THREE.Raycaster();
+      this.pickedObject = null;
+      this.pickedObjectSavedColor = 0;
+    }
+    pick(normalizedPosition, scene, camera, time) {
+      // restore the color if there is a picked object
+      if (this.pickedObject) {
+        this.pickedObject.material.emissive.setHex(this.pickedObjectSavedColor);
+        this.pickedObject = undefined;
+      }
+
+      // cast a ray through the frustum
+      this.raycaster.setFromCamera(normalizedPosition, camera);
+      // get the list of objects the ray intersected
+      const intersectedObjects = this.raycaster.intersectObjects(scene.children);
+      if (intersectedObjects.length) {
+        // pick the first object. It's the closest one
+        this.pickedObject = intersectedObjects[0].object;
+        // save its color
+        this.pickedObjectSavedColor = this.pickedObject.material.emissive.getHex();
+        // set its emissive color to flashing red/yellow
+        this.pickedObject.material.emissive.setHex((time * 8) % 2 > 1 ? 0xFFFF00 : 0xFF0000);
+      }
+    }
+  }
+
+  const pickPosition = {x: -2, y: -2};
+  const pickHelper = new PickHelper();
+  clearPickPosition();
+
+  function resizeRendererToDisplaySize(renderer) {
+    const canvas = renderer.domElement;
+    const width = inputElement.clientWidth;
+    const height = inputElement.clientHeight;
+    const needResize = canvas.width !== width || canvas.height !== height;
+    if (needResize) {
+      renderer.setSize(width, height, false);
+    }
+    return needResize;
+  }
+
+  function render(time) {
+    time *= 0.001;
+
+    if (resizeRendererToDisplaySize(renderer)) {
+      camera.aspect = inputElement.clientWidth / inputElement.clientHeight;
+      camera.updateProjectionMatrix();
+    }
+
+    cubes.forEach((cube, ndx) => {
+      const speed = 1 + ndx * .1;
+      const rot = time * speed;
+      cube.rotation.x = rot;
+      cube.rotation.y = rot;
+    });
+
+    pickHelper.pick(pickPosition, scene, camera, time);
+
+    renderer.render(scene, camera);
+
+    requestAnimationFrame(render);
+  }
+
+  requestAnimationFrame(render);
+
+  function setPickPosition(event) {
+    pickPosition.x = (event.clientX / inputElement.clientWidth ) *  2 - 1;
+    pickPosition.y = (event.clientY / inputElement.clientHeight) * -2 + 1;  // note we flip Y
+  }
+
+  function clearPickPosition() {
+    // unlike the mouse which always has a position
+    // if the user stops touching the screen we want
+    // to stop picking. For now we just pick a value
+    // unlikely to pick something
+    pickPosition.x = -100000;
+    pickPosition.y = -100000;
+  }
+
+  inputElement.addEventListener('mousemove', setPickPosition);
+  inputElement.addEventListener('mouseout', clearPickPosition);
+  inputElement.addEventListener('mouseleave', clearPickPosition);
+
+  inputElement.addEventListener('touchstart', (event) => {
+    // prevent the window from scrolling
+    event.preventDefault();
+    setPickPosition(event.touches[0]);
+  }, {passive: false});
+
+  inputElement.addEventListener('touchmove', (event) => {
+    setPickPosition(event.touches[0]);
+  });
+
+  inputElement.addEventListener('touchend', clearPickPosition);
+}
+

+ 125 - 0
threejs/shared-picking.js

@@ -0,0 +1,125 @@
+'use strict';
+
+/* global THREE */
+
+const state = {
+  width: 300,   // canvas default
+  height: 150,  // canvas default
+};
+
+const pickPosition = {x: 0, y: 0};
+
+function init(data) {  // eslint-disable-line no-unused-vars
+  const {canvas} = data;
+  const renderer = new THREE.WebGLRenderer({canvas});
+
+  state.width = canvas.width;
+  state.height = canvas.height;
+
+  const fov = 75;
+  const aspect = 2; // the canvas default
+  const near = 0.1;
+  const far = 100;
+  const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
+  camera.position.z = 4;
+
+  const scene = new THREE.Scene();
+
+  {
+    const color = 0xFFFFFF;
+    const intensity = 1;
+    const light = new THREE.DirectionalLight(color, intensity);
+    light.position.set(-1, 2, 4);
+    scene.add(light);
+  }
+
+  const boxWidth = 1;
+  const boxHeight = 1;
+  const boxDepth = 1;
+  const geometry = new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth);
+
+  function makeInstance(geometry, color, x) {
+    const material = new THREE.MeshPhongMaterial({
+      color,
+    });
+
+    const cube = new THREE.Mesh(geometry, material);
+    scene.add(cube);
+
+    cube.position.x = x;
+
+    return cube;
+  }
+
+  const cubes = [
+    makeInstance(geometry, 0x44aa88, 0),
+    makeInstance(geometry, 0x8844aa, -2),
+    makeInstance(geometry, 0xaa8844, 2),
+  ];
+
+  class PickHelper {
+    constructor() {
+      this.raycaster = new THREE.Raycaster();
+      this.pickedObject = null;
+      this.pickedObjectSavedColor = 0;
+    }
+    pick(normalizedPosition, scene, camera, time) {
+      // restore the color if there is a picked object
+      if (this.pickedObject) {
+        this.pickedObject.material.emissive.setHex(this.pickedObjectSavedColor);
+        this.pickedObject = undefined;
+      }
+
+      // cast a ray through the frustum
+      this.raycaster.setFromCamera(normalizedPosition, camera);
+      // get the list of objects the ray intersected
+      const intersectedObjects = this.raycaster.intersectObjects(scene.children);
+      if (intersectedObjects.length) {
+        // pick the first object. It's the closest one
+        this.pickedObject = intersectedObjects[0].object;
+        // save its color
+        this.pickedObjectSavedColor = this.pickedObject.material.emissive.getHex();
+        // set its emissive color to flashing red/yellow
+        this.pickedObject.material.emissive.setHex((time * 8) % 2 > 1 ? 0xFFFF00 : 0xFF0000);
+      }
+    }
+  }
+
+  const pickHelper = new PickHelper();
+
+  function resizeRendererToDisplaySize(renderer) {
+    const canvas = renderer.domElement;
+    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;
+  }
+
+  function render(time) {
+    time *= 0.001;
+
+    if (resizeRendererToDisplaySize(renderer)) {
+      camera.aspect = state.width / state.height;
+      camera.updateProjectionMatrix();
+    }
+
+    cubes.forEach((cube, ndx) => {
+      const speed = 1 + ndx * .1;
+      const rot = time * speed;
+      cube.rotation.x = rot;
+      cube.rotation.y = rot;
+    });
+
+    pickHelper.pick(pickPosition, scene, camera, time);
+
+    renderer.render(scene, camera);
+
+    requestAnimationFrame(render);
+  }
+
+  requestAnimationFrame(render);
+}
+

+ 73 - 0
threejs/threejs-offscreencanvas-w-fallback.html

@@ -0,0 +1,73 @@
+<!-- Licensed under a BSD license. See license.html for license -->
+<!DOCTYPE html>
+<html>
+  <head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes">
+    <title>Three.js - OffscreenCanvas w/Fallback</title>
+    <style>
+    body {
+        margin: 0;
+    }
+    #c {
+        width: 100vw;
+        height: 100vh;
+        display: block;
+    }
+    </style>
+  </head>
+  <body>
+    <canvas id="c"></canvas>
+  </body>
+<script src="resources/threejs/r103/three.min.js"></script>
+<script src="shared-cubes.js"></script>
+<script>
+'use strict';
+
+/* global state, init */
+
+function startWorker(canvas) {
+  const offscreen = canvas.transferControlToOffscreen();
+  const worker = new Worker('offscreencanvas-worker-cubes.js');
+  worker.postMessage({type: 'init', canvas: offscreen}, [offscreen]);
+
+  function sendSize() {
+    worker.postMessage({
+      type: 'size',
+      width: canvas.clientWidth,
+      height: canvas.clientHeight,
+    });
+  }
+
+  window.addEventListener('resize', sendSize);
+  sendSize();
+
+  console.log('using OffscreenCanvas');  /* eslint-disable-line no-console */
+}
+
+function startMainPage(canvas) {
+  init({canvas});
+
+  function sendSize() {
+    state.width = canvas.clientWidth;
+    state.height = canvas.clientHeight;
+  }
+  window.addEventListener('resize', sendSize);
+  sendSize();
+
+  console.log('using regular canvas');  /* eslint-disable-line no-console */
+}
+
+function main() {  /* eslint consistent-return: 0 */
+  const canvas = document.querySelector('#c');
+  if (canvas.transferControlToOffscreen) {
+    startWorker(canvas);
+  } else {
+    startMainPage(canvas);
+  }
+}
+
+main();
+</script>
+</html>
+

+ 185 - 0
threejs/threejs-offscreencanvas-w-orbitcontrols.html

@@ -0,0 +1,185 @@
+<!-- Licensed under a BSD license. See license.html for license -->
+<!DOCTYPE html>
+<html>
+  <head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes">
+    <title>Three.js - Offscreen Canvas</title>
+    <style>
+    body {
+        margin: 0;
+    }
+    #c {
+        width: 100vw;
+        height: 100vh;
+        display: block;
+    }
+    #c:focus {
+      outline: none;
+    }
+    </style>
+  </head>
+  <body>
+    <canvas id="c" tabindex="1"></canvas>
+  </body>
+<script src="resources/threejs/r103/three.min.js"></script>
+<script src="resources/threejs/r103/js/controls/OrbitControls.js"></script>
+<script src="shared-orbitcontrols.js"></script>
+<script>
+'use strict';
+
+/* global init */
+
+const mouseEventHandler = makeSendPropertiesHandler([
+  'ctrlKey',
+  'metaKey',
+  'shiftKey',
+  'button',
+  'clientX',
+  'clientY',
+  'pageX',
+  'pageY',
+]);
+const wheelEventHandlerImpl = makeSendPropertiesHandler([
+  'deltaX',
+  'deltaY',
+]);
+const keydownEventHandler = makeSendPropertiesHandler([
+  'ctrlKey',
+  'metaKey',
+  'shiftKey',
+  'keyCode',
+]);
+
+function wheelEventHandler(event, sendFn) {
+  event.preventDefault();
+  wheelEventHandlerImpl(event, sendFn);
+}
+
+function preventDefaultHandler(event) {
+  event.preventDefault();
+}
+
+function copyProperties(src, properties, dst) {
+  for (const name of properties) {
+      dst[name] = src[name];
+  }
+}
+
+function makeSendPropertiesHandler(properties) {
+  return function sendProperties(event, sendFn) {
+    const data = {type: event.type};
+    copyProperties(event, properties, data);
+    sendFn(data);
+  };
+}
+
+function touchEventHandler(event, sendFn) {
+  const touches = [];
+  const data = {type: event.type, touches};
+  for (let i = 0; i < event.touches.length; ++i) {
+    const touch = event.touches[i];
+    touches.push({
+      pageX: touch.pageX,
+      pageY: touch.pageY,
+    });
+  }
+  sendFn(data);
+}
+
+// The four arrow keys
+const orbitKeys = {
+  '37': true,  // left
+  '38': true,  // up
+  '39': true,  // right
+  '40': true,  // down
+};
+function filteredKeydownEventHandler(event, sendFn) {
+  const {keyCode} = event;
+  if (orbitKeys[keyCode]) {
+    event.preventDefault();
+    keydownEventHandler(event, sendFn);
+  }
+}
+
+let nextProxyId = 0;
+class ElementProxy {
+  constructor(element, worker, eventHandlers) {
+    this.id = nextProxyId++;
+    this.worker = worker;
+    const sendEvent = (data) => {
+      this.worker.postMessage({
+        type: 'event',
+        id: this.id,
+        data,
+      });
+    };
+
+    // register an id
+    worker.postMessage({
+      type: 'makeProxy',
+      id: this.id,
+    });
+    sendSize();
+    for (const [eventName, handler] of Object.entries(eventHandlers)) {
+      element.addEventListener(eventName, function(event) {
+        handler(event, sendEvent);
+      });
+    }
+
+    function sendSize() {
+      sendEvent({
+        type: 'size',
+        width: element.clientWidth,
+        height: element.clientHeight,
+      });
+    }
+
+    // really need to use ResizeObserver
+    window.addEventListener('resize', sendSize);
+  }
+}
+
+function startWorker(canvas) {
+  canvas.focus();
+  const offscreen = canvas.transferControlToOffscreen();
+  const worker = new Worker('offscreencanvas-worker-orbitcontrols.js');
+
+  const eventHandlers = {
+    contextmenu: preventDefaultHandler,
+    mousedown: mouseEventHandler,
+    mousemove: mouseEventHandler,
+    mouseup: mouseEventHandler,
+    touchstart: touchEventHandler,
+    touchmove: touchEventHandler,
+    touchend: touchEventHandler,
+    wheel: wheelEventHandler,
+    keydown: filteredKeydownEventHandler,
+  };
+  const proxy = new ElementProxy(canvas, worker, eventHandlers);
+  worker.postMessage({
+    type: 'start',
+    canvas: offscreen,
+    canvasId: proxy.id,
+  }, [offscreen]);
+  console.log('using OffscreenCanvas');  /* eslint-disable-line no-console */
+}
+
+function startMainPage(canvas) {
+  init({canvas, inputElement: canvas});
+  console.log('using regular canvas');  /* eslint-disable-line no-console */
+}
+
+function main() {  /* eslint consistent-return: 0 */
+  const canvas = document.querySelector('#c');
+  if (canvas.transferControlToOffscreen) {
+    startWorker(canvas);
+  } else {
+    startMainPage(canvas);
+  }
+}
+
+main();
+</script>
+</html>
+

+ 117 - 0
threejs/threejs-offscreencanvas-w-picking.html

@@ -0,0 +1,117 @@
+<!-- Licensed under a BSD license. See license.html for license -->
+<!DOCTYPE html>
+<html>
+  <head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes">
+    <title>Three.js - OffscreenCanvas Picking</title>
+    <style>
+    body {
+        margin: 0;
+    }
+    #c {
+        width: 100vw;
+        height: 100vh;
+        display: block;
+    }
+    </style>
+  </head>
+  <body>
+    <canvas id="c"></canvas>
+  </body>
+<script src="resources/threejs/r103/three.min.js"></script>
+<script src="shared-picking.js"></script>
+<script>
+'use strict';
+
+/* global state, init, pickPosition */
+
+let sendMouse;
+
+function startWorker(canvas) {
+  const offscreen = canvas.transferControlToOffscreen();
+  const worker = new Worker('offscreencanvas-worker-picking.js');
+  worker.postMessage({type: 'init', canvas: offscreen}, [offscreen]);
+
+  sendMouse = (x, y) => {
+    worker.postMessage({
+      type: 'mouse',
+      x,
+      y,
+    });
+  };
+
+  function sendSize() {
+    worker.postMessage({
+      type: 'size',
+      width: canvas.clientWidth,
+      height: canvas.clientHeight,
+    });
+  }
+
+  window.addEventListener('resize', sendSize);
+  sendSize();
+
+  console.log('using OffscreenCanvas');  /* eslint-disable-line no-console */
+}
+
+function startMainPage(canvas) {
+  init({canvas});
+
+  sendMouse = (x, y) => {
+    pickPosition.x = x;
+    pickPosition.y = y;
+  };
+
+  function sendSize() {
+    state.width = canvas.clientWidth;
+    state.height = canvas.clientHeight;
+  }
+  window.addEventListener('resize', sendSize);
+  sendSize();
+
+  console.log('using regular canvas');  /* eslint-disable-line no-console */
+}
+
+function main() {  /* eslint consistent-return: 0 */
+  const canvas = document.querySelector('#c');
+  if (canvas.transferControlToOffscreen) {
+    startWorker(canvas);
+  } else {
+    startMainPage(canvas);
+  }
+
+  function setPickPosition(event) {
+    sendMouse(
+        (event.clientX / canvas.clientWidth ) *  2 - 1,
+        (event.clientY / canvas.clientHeight) * -2 + 1);  // note we flip Y
+  }
+
+  function clearPickPosition() {
+    // unlike the mouse which always has a position
+    // if the user stops touching the screen we want
+    // to stop picking. For now we just pick a value
+    // unlikely to pick something
+    sendMouse(-100000, -100000);
+  }
+  window.addEventListener('mousemove', setPickPosition);
+  window.addEventListener('mouseout', clearPickPosition);
+  window.addEventListener('mouseleave', clearPickPosition);
+
+  window.addEventListener('touchstart', (event) => {
+    // prevent the window from scrolling
+    event.preventDefault();
+    setPickPosition(event.touches[0]);
+  }, {passive: false});
+
+  window.addEventListener('touchmove', (event) => {
+    setPickPosition(event.touches[0]);
+  });
+
+  window.addEventListener('touchend', clearPickPosition);
+}
+main();
+
+</script>
+</html>
+

+ 63 - 0
threejs/threejs-offscreencanvas.html

@@ -0,0 +1,63 @@
+<!-- Licensed under a BSD license. See license.html for license -->
+<!DOCTYPE html>
+<html>
+  <head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes">
+    <title>Three.js - OffscreenCanvas</title>
+    <style>
+    body {
+        margin: 0;
+    }
+    #c {
+        width: 100vw;
+        height: 100vh;
+        display: block;
+    }
+    #noOffscreenCanvas {
+        display: flex;
+        width: 100vw;
+        height: 100vh;
+        align-items: center;
+        justify-content: center;
+        background: red;
+        color: white;
+    }
+    </style>
+  </head>
+  <body>
+    <canvas id="c"></canvas>
+    <div id="noOffscreenCanvas" style="display:none;">
+      <div>no OffscreenCanvas support</div>
+    </div>
+  </body>
+<script>
+'use strict';
+
+function main() {  /* eslint consistent-return: 0 */
+  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-cubes.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();
+}
+main();
+
+</script>
+</html>
+