12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088 |
- <!DOCTYPE html><html lang="en"><head>
- <meta charset="utf-8">
- <title>OffscreenCanvas</title>
- <meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
- <meta name="twitter:card" content="summary_large_image">
- <meta name="twitter:site" content="@threejs">
- <meta name="twitter:title" content="Three.js – OffscreenCanvas">
- <meta property="og:image" content="https://threejs.org/files/share.png">
- <link rel="shortcut icon" href="../../files/favicon_white.ico" media="(prefers-color-scheme: dark)">
- <link rel="shortcut icon" href="../../files/favicon.ico" media="(prefers-color-scheme: light)">
- <link rel="stylesheet" href="../resources/lesson.css">
- <link rel="stylesheet" href="../resources/lang.css">
- <script type="importmap">
- {
- "imports": {
- "three": "../../build/three.module.js"
- }
- }
- </script>
- </head>
- <body>
- <div class="container">
- <div class="lesson-title">
- <h1>OffscreenCanvas</h1>
- </div>
- <div class="lesson">
- <div class="lesson-main">
- <p><a href="https://developer.mozilla.org/en-US/docs/Web/API/OffscreenCanvas"><code class="notranslate" translate="no">OffscreenCanvas</code></a>
- is a relatively new browser feature currently only available in Chrome but apparently
- coming to other browsers. <code class="notranslate" translate="no">OffscreenCanvas</code> 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.</p>
- <p>Getting <em>started</em> using it is pretty straight forward. Let's port the 3 spinning cube
- example from <a href="responsive.html">the article on responsiveness</a>.</p>
- <p>Workers generally have their code separated
- into another script file whereas most of the examples on this site have had
- their scripts embedded into the HTML file of the page they are on.</p>
- <p>In our case we'll make a file called <code class="notranslate" translate="no">offscreencanvas-cubes.js</code> and
- copy all the JavaScript from <a href="responsive.html">the responsive example</a> into it. We'll then
- make the changes needed for it to run in a worker.</p>
- <p>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 <code class="notranslate" translate="no">canvas.transferControlToOffscreen</code>.</p>
- <pre class="prettyprint showlinemods notranslate lang-js" translate="no">function main() {
- const canvas = document.querySelector('#c');
- const offscreen = canvas.transferControlToOffscreen();
- ...
- </pre>
- <p>We can then start our worker with <code class="notranslate" translate="no">new Worker(pathToScript, {type: 'module'})</code>.
- and pass the <code class="notranslate" translate="no">offscreen</code> object to it.</p>
- <pre class="prettyprint showlinemods notranslate lang-js" translate="no">function main() {
- const canvas = document.querySelector('#c');
- const offscreen = canvas.transferControlToOffscreen();
- const worker = new Worker('offscreencanvas-cubes.js', {type: 'module'});
- worker.postMessage({type: 'main', canvas: offscreen}, [offscreen]);
- }
- main();
- </pre>
- <p>It's important to note that workers can't access the <code class="notranslate" translate="no">DOM</code>. 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 and send messages back to the page.</p>
- <p>To send a message to a worker we call <a href="https://developer.mozilla.org/en-US/docs/Web/API/Worker/postMessage"><code class="notranslate" translate="no">worker.postMessage</code></a> and
- pass it 1 or 2 arguments. The first argument is a JavaScript object
- that will be <a href="https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm">cloned</a>
- and sent to the worker. The second argument is an optional array
- of objects that are part of the first object that we want <em>transferred</em>
- to the worker. These objects will not be cloned. Instead they will be <em>transferred</em>
- 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 <code class="notranslate" translate="no">OffscreenCanvas</code>
- so once transferred the <code class="notranslate" translate="no">offscreen</code> object back in the main page is useless.</p>
- <p>Workers receive messages from their <code class="notranslate" translate="no">onmessage</code> handler. The object
- we passed to <code class="notranslate" translate="no">postMessage</code> arrives on <code class="notranslate" translate="no">event.data</code> passed to the <code class="notranslate" translate="no">onmessage</code>
- handler on the worker. The code above declares a <code class="notranslate" translate="no">type: 'main'</code> in the object it passes
- to the worker. This object has no meaning to the browser. It's entirely for
- our own usage. We'll make a handler that based on <code class="notranslate" translate="no">type</code> calls
- a different function in the worker. Then we can add functions as
- needed and easily call them from the main page.</p>
- <pre class="prettyprint showlinemods notranslate lang-js" translate="no">const handlers = {
- main,
- };
- self.onmessage = function(e) {
- const fn = handlers[e.data.type];
- if (typeof fn !== 'function') {
- throw new Error('no handler for type: ' + e.data.type);
- }
- fn(e.data);
- };
- </pre>
- <p>You can see above we just look up the handler based on the <code class="notranslate" translate="no">type</code> pass it the <code class="notranslate" translate="no">data</code>
- that was sent from the main page.</p>
- <p>So now we just need to start changing the <code class="notranslate" translate="no">main</code> we pasted into
- <code class="notranslate" translate="no">offscreencanvas-cubes.js</code> from <a href="responsive.html">the responsive article</a>.</p>
- <p>Instead of looking up the canvas from the DOM we'll receive it from the
- event data.</p>
- <pre class="prettyprint showlinemods notranslate lang-js" translate="no">-function main() {
- - const canvas = document.querySelector('#c');
- +function main(data) {
- + const {canvas} = data;
- const renderer = new THREE.WebGLRenderer({antialias: true, canvas});
- ...
- </pre>
- <p>Remembering that workers can't see the DOM at all the first problem
- we run into is <code class="notranslate" translate="no">resizeRendererToDisplaySize</code> can't look at <code class="notranslate" translate="no">canvas.clientWidth</code>
- and <code class="notranslate" translate="no">canvas.clientHeight</code> as those are DOM values. Here's the original code</p>
- <pre class="prettyprint showlinemods notranslate lang-js" translate="no">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;
- }
- </pre>
- <p>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.</p>
- <pre class="prettyprint showlinemods notranslate lang-js" translate="no">const state = {
- width: 300, // canvas default
- height: 150, // canvas default
- };
- </pre>
- <p>Then let's add a <code class="notranslate" translate="no">'size'</code> handler to update those values. </p>
- <pre class="prettyprint showlinemods notranslate lang-js" translate="no">+function size(data) {
- + state.width = data.width;
- + state.height = data.height;
- +}
- const handlers = {
- main,
- + size,
- };
- </pre>
- <p>Now we can change <code class="notranslate" translate="no">resizeRendererToDisplaySize</code> to use <code class="notranslate" translate="no">state.width</code> and <code class="notranslate" translate="no">state.height</code></p>
- <pre class="prettyprint showlinemods notranslate lang-js" translate="no">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;
- }
- </pre>
- <p>and where we compute the aspect we need similar changes</p>
- <pre class="prettyprint showlinemods notranslate lang-js" translate="no">function render(time) {
- time *= 0.001;
- if (resizeRendererToDisplaySize(renderer)) {
- - camera.aspect = canvas.clientWidth / canvas.clientHeight;
- + camera.aspect = state.width / state.height;
- camera.updateProjectionMatrix();
- }
- ...
- </pre>
- <p>Back in the main page we'll send a <code class="notranslate" translate="no">size</code> event anytime the page changes size.</p>
- <pre class="prettyprint showlinemods notranslate lang-js" translate="no">const worker = new Worker('offscreencanvas-picking.js', {type: 'module'});
- worker.postMessage({type: 'main', canvas: offscreen}, [offscreen]);
- +function sendSize() {
- + worker.postMessage({
- + type: 'size',
- + width: canvas.clientWidth,
- + height: canvas.clientHeight,
- + });
- +}
- +
- +window.addEventListener('resize', sendSize);
- +sendSize();
- </pre>
- <p>We also call it once to send the initial size.</p>
- <p>And with just those few changes, assuming your browser fully supports <code class="notranslate" translate="no">OffscreenCanvas</code>
- it should work. Before we run it though let's check if the browser actually supports
- <code class="notranslate" translate="no">OffscreenCanvas</code> and if not display an error. First let's add some HTML to display the error.</p>
- <pre class="prettyprint showlinemods notranslate lang-html" translate="no"><body>
- <canvas id="c"></canvas>
- + <div id="noOffscreenCanvas" style="display:none;">
- + <div>no OffscreenCanvas support</div>
- + </div>
- </body>
- </pre>
- <p>and some CSS for that</p>
- <pre class="prettyprint showlinemods notranslate lang-css" translate="no">#noOffscreenCanvas {
- display: flex;
- width: 100%;
- height: 100%;
- align-items: center;
- justify-content: center;
- background: red;
- color: white;
- }
- </pre>
- <p>and then we can check for the existence of <code class="notranslate" translate="no">transferControlToOffscreen</code> to see
- if the browser supports <code class="notranslate" translate="no">OffscreenCanvas</code></p>
- <pre class="prettyprint showlinemods notranslate lang-js" translate="no">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', {type: 'module});
- worker.postMessage({type: 'main', canvas: offscreen}, [offscreen]);
- ...
- </pre>
- <p>and with that, if your browser supports <code class="notranslate" translate="no">OffscreenCanvas</code> this example should work</p>
- <p></p><div translate="no" class="threejs_example_container notranslate">
- <div><iframe class="threejs_example notranslate" translate="no" style=" " src="/manual/examples/resources/editor.html?url=/manual/examples/offscreencanvas.html"></iframe></div>
- <a class="threejs_center" href="/manual/examples/offscreencanvas.html" target="_blank">click here to open in a separate window</a>
- </div>
- <p></p>
- <p>So that's great but since not every browser supports <code class="notranslate" translate="no">OffscreenCanvas</code> at the moment
- let's change the code to work with both <code class="notranslate" translate="no">OffscreenCanvas</code> and if not then fallback to using
- the canvas in the main page like normal.</p>
- <blockquote>
- <p>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.</p>
- </blockquote>
- <p>The first thing we should probably do is separate out the three.js
- code from the code that is specific to the worker. That way we can
- use the same code on both the main page and the worker. In other words
- we will now have 3 files</p>
- <ol>
- <li><p>our html file.</p>
- <p><code class="notranslate" translate="no">threejs-offscreencanvas-w-fallback.html</code></p>
- </li>
- <li><p>a JavaScript that contains our three.js code.</p>
- <p><code class="notranslate" translate="no">shared-cubes.js</code></p>
- </li>
- <li><p>our worker support code</p>
- <p><code class="notranslate" translate="no">offscreencanvas-worker-cubes.js</code></p>
- </li>
- </ol>
- <p><code class="notranslate" translate="no">shared-cubes.js</code> and <code class="notranslate" translate="no">offscreencanvas-worker-cubes.js</code> are basically
- the split of our previous <code class="notranslate" translate="no">offscreencanvas-cubes.js</code> file. First we
- copy all of <code class="notranslate" translate="no">offscreencanvas-cubes.js</code> to <code class="notranslate" translate="no">shared-cube.js</code>. Then
- we rename <code class="notranslate" translate="no">main</code> to <code class="notranslate" translate="no">init</code> since we already have a <code class="notranslate" translate="no">main</code> in our
- HTML file and we need to export <code class="notranslate" translate="no">init</code> and <code class="notranslate" translate="no">state</code></p>
- <pre class="prettyprint showlinemods notranslate lang-js" translate="no">import * as THREE from 'three';
- -const state = {
- +export const state = {
- width: 300, // canvas default
- height: 150, // canvas default
- };
- -function main(data) {
- +export function init(data) {
- const {canvas} = data;
- const renderer = new THREE.WebGLRenderer({antialias: true, canvas});
- </pre>
- <p>and cut out the just the non three.js relates parts</p>
- <pre class="prettyprint showlinemods notranslate lang-js" translate="no">-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 (typeof fn !== 'function') {
- - throw new Error('no handler for type: ' + e.data.type);
- - }
- - fn(e.data);
- -};
- </pre>
- <p>Then we copy those parts we just deleted to <code class="notranslate" translate="no">offscreencanvas-worker-cubes.js</code>
- and import <code class="notranslate" translate="no">shared-cubes.js</code> as well as call <code class="notranslate" translate="no">init</code> instead of <code class="notranslate" translate="no">main</code>.</p>
- <pre class="prettyprint showlinemods notranslate lang-js" translate="no">import {init, state} from './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 (typeof fn !== 'function') {
- throw new Error('no handler for type: ' + e.data.type);
- }
- fn(e.data);
- };
- </pre>
- <p>Similarly we need to include <code class="notranslate" translate="no">shared-cubes.js</code> in the main page</p>
- <pre class="prettyprint showlinemods notranslate lang-html" translate="no"><script type="module">
- +import {init, state} from './shared-cubes.js';
- </pre>
- <p>We can remove the HTML and CSS we added previously</p>
- <pre class="prettyprint showlinemods notranslate lang-html" translate="no"><body>
- <canvas id="c"></canvas>
- - <div id="noOffscreenCanvas" style="display:none;">
- - <div>no OffscreenCanvas support</div>
- - </div>
- </body>
- </pre>
- <p>and some CSS for that</p>
- <pre class="prettyprint showlinemods notranslate lang-css" translate="no">-#noOffscreenCanvas {
- - display: flex;
- - width: 100%;
- - height: 100%;
- - align-items: center;
- - justify-content: center;
- - background: red;
- - color: white;
- -}
- </pre>
- <p>Then let's change the code in the main page to call one start
- function or another depending on if the browser supports <code class="notranslate" translate="no">OffscreenCanvas</code>.</p>
- <pre class="prettyprint showlinemods notranslate lang-js" translate="no">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', {type: 'module'});
- - worker.postMessage({type: 'main', canvas: offscreen}, [offscreen]);
- + if (canvas.transferControlToOffscreen) {
- + startWorker(canvas);
- + } else {
- + startMainPage(canvas);
- + }
- ...
- </pre>
- <p>We'll move all the code we had to setup the worker inside <code class="notranslate" translate="no">startWorker</code></p>
- <pre class="prettyprint showlinemods notranslate lang-js" translate="no">function startWorker(canvas) {
- const offscreen = canvas.transferControlToOffscreen();
- const worker = new Worker('offscreencanvas-worker-cubes.js', {type: 'module'});
- worker.postMessage({type: 'main', canvas: offscreen}, [offscreen]);
- function sendSize() {
- worker.postMessage({
- type: 'size',
- width: canvas.clientWidth,
- height: canvas.clientHeight,
- });
- }
- window.addEventListener('resize', sendSize);
- sendSize();
- console.log('using OffscreenCanvas');
- }
- </pre>
- <p>and send <code class="notranslate" translate="no">init</code> instead of <code class="notranslate" translate="no">main</code></p>
- <pre class="prettyprint showlinemods notranslate lang-js" translate="no">- worker.postMessage({type: 'main', canvas: offscreen}, [offscreen]);
- + worker.postMessage({type: 'init', canvas: offscreen}, [offscreen]);
- </pre>
- <p>for starting in the main page we can do this</p>
- <pre class="prettyprint showlinemods notranslate lang-js" translate="no">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');
- }
- </pre>
- <p>and with that our example will run either in an OffscreenCanvas or
- fallback to running in the main page.</p>
- <p></p><div translate="no" class="threejs_example_container notranslate">
- <div><iframe class="threejs_example notranslate" translate="no" style=" " src="/manual/examples/resources/editor.html?url=/manual/examples/offscreencanvas-w-fallback.html"></iframe></div>
- <a class="threejs_center" href="/manual/examples/offscreencanvas-w-fallback.html" target="_blank">click here to open in a separate window</a>
- </div>
- <p></p>
- <p>So that was relatively easy. Let's try picking. We'll take some code from
- the <code class="notranslate" translate="no">RayCaster</code> example from <a href="picking.html">the article on picking</a>
- and make it work offscreen.</p>
- <p>Let's copy the <code class="notranslate" translate="no">shared-cube.js</code> to <code class="notranslate" translate="no">shared-picking.js</code> and add the
- picking parts. We copy in the <code class="notranslate" translate="no">PickHelper</code> </p>
- <pre class="prettyprint showlinemods notranslate lang-js" translate="no">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();
- </pre>
- <p>We updated <code class="notranslate" translate="no">pickPosition</code> from the mouse like this</p>
- <pre class="prettyprint showlinemods notranslate lang-js" translate="no">function getCanvasRelativePosition(event) {
- const rect = canvas.getBoundingClientRect();
- return {
- x: (event.clientX - rect.left) * canvas.width / rect.width,
- y: (event.clientY - rect.top ) * canvas.height / rect.height,
- };
- }
- function setPickPosition(event) {
- const pos = getCanvasRelativePosition(event);
- pickPosition.x = (pos.x / canvas.width ) * 2 - 1;
- pickPosition.y = (pos.y / canvas.height) * -2 + 1; // note we flip Y
- }
- window.addEventListener('mousemove', setPickPosition);
- </pre>
- <p>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 <code class="notranslate" translate="no">pickPosition</code></p>
- <pre class="prettyprint showlinemods notranslate lang-js" translate="no">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 (typeof fn !== 'function') {
- throw new Error('no handler for type: ' + e.data.type);
- }
- fn(e.data);
- };
- </pre>
- <p>Back in our main page we need to add code to pass the mouse
- to the worker or the main page.</p>
- <pre class="prettyprint showlinemods notranslate lang-js" translate="no">+let sendMouse;
- function startWorker(canvas) {
- const offscreen = canvas.transferControlToOffscreen();
- const worker = new Worker('offscreencanvas-worker-picking.js', {type: 'module'});
- 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 */
- }
- </pre>
- <p>Then we can copy in all the mouse handling code to the main page and
- make just minor changes to use <code class="notranslate" translate="no">sendMouse</code></p>
- <pre class="prettyprint showlinemods notranslate lang-js" translate="no">function setPickPosition(event) {
- const pos = getCanvasRelativePosition(event);
- - pickPosition.x = (pos.x / canvas.clientWidth ) * 2 - 1;
- - pickPosition.y = (pos.y / canvas.clientHeight) * -2 + 1; // note we flip Y
- + sendMouse(
- + (pos.x / canvas.clientWidth ) * 2 - 1,
- + (pos.y / 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);
- </pre>
- <p>and with that picking should be working with <code class="notranslate" translate="no">OffscreenCanvas</code>.</p>
- <p></p><div translate="no" class="threejs_example_container notranslate">
- <div><iframe class="threejs_example notranslate" translate="no" style=" " src="/manual/examples/resources/editor.html?url=/manual/examples/offscreencanvas-w-picking.html"></iframe></div>
- <a class="threejs_center" href="/manual/examples/offscreencanvas-w-picking.html" target="_blank">click here to open in a separate window</a>
- </div>
- <p></p>
- <p>Let's take it one more step and add in the <a href="/docs/#examples/controls/OrbitControls"><code class="notranslate" translate="no">OrbitControls</code></a>.
- This will be little more involved. The <a href="/docs/#examples/controls/OrbitControls"><code class="notranslate" translate="no">OrbitControls</code></a> use
- the DOM pretty extensively checking the mouse, touch events,
- and the keyboard.</p>
- <p>Unlike our code so far we can't really use a global <code class="notranslate" translate="no">state</code> object
- without re-writing all the OrbitControls code to work with it.
- The OrbitControls take an <code class="notranslate" translate="no">HTMLElement</code> 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.</p>
- <p>Digging through the <a href="https://github.com/mrdoob/three.js/blob/master/examples/jsm/controls/OrbitControls.js">OrbitControls source code</a>
- it looks like we need to handle the following events.</p>
- <ul>
- <li>contextmenu</li>
- <li>pointerdown</li>
- <li>pointermove</li>
- <li>pointerup</li>
- <li>touchstart</li>
- <li>touchmove</li>
- <li>touchend</li>
- <li>wheel</li>
- <li>keydown</li>
- </ul>
- <p>For the pointer events we need the <code class="notranslate" translate="no">ctrlKey</code>, <code class="notranslate" translate="no">metaKey</code>, <code class="notranslate" translate="no">shiftKey</code>,
- <code class="notranslate" translate="no">button</code>, <code class="notranslate" translate="no">pointerType</code>, <code class="notranslate" translate="no">clientX</code>, <code class="notranslate" translate="no">clientY</code>, <code class="notranslate" translate="no">pageX</code>, and <code class="notranslate" translate="no">pageY</code>, properties.</p>
- <p>For the keydown events we need the <code class="notranslate" translate="no">ctrlKey</code>, <code class="notranslate" translate="no">metaKey</code>, <code class="notranslate" translate="no">shiftKey</code>,
- and <code class="notranslate" translate="no">keyCode</code> properties.</p>
- <p>For the wheel event we only need the <code class="notranslate" translate="no">deltaY</code> property.</p>
- <p>And for the touch events we only need <code class="notranslate" translate="no">pageX</code> and <code class="notranslate" translate="no">pageY</code> from
- the <code class="notranslate" translate="no">touches</code> property.</p>
- <p>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.</p>
- <p>Here's the code for the worker part.</p>
- <pre class="prettyprint showlinemods notranslate lang-js" translate="no">import {EventDispatcher} from 'three';
- class ElementProxyReceiver extends EventDispatcher {
- constructor() {
- super();
- }
- handleEvent(data) {
- this.dispatchEvent(data);
- }
- }
- </pre>
- <p>All it does is if it receives a message it dispatches it.
- It inherits from <a href="/docs/#api/en/core/EventDispatcher"><code class="notranslate" translate="no">EventDispatcher</code></a> which provides methods like
- <code class="notranslate" translate="no">addEventListener</code> and <code class="notranslate" translate="no">removeEventListener</code> just like a DOM
- element so if we pass it to the OrbitControls it should work.</p>
- <p><code class="notranslate" translate="no">ElementProxyReceiver</code> 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.</p>
- <pre class="prettyprint showlinemods notranslate lang-js" translate="no">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);
- }
- }
- </pre>
- <p>We can make a instance of <code class="notranslate" translate="no">ProxyManager</code> and call its <code class="notranslate" translate="no">makeProxy</code>
- method with an id which will make an <code class="notranslate" translate="no">ElementProxyReceiver</code> that
- responds to messages with that id.</p>
- <p>Let's hook it up to our worker's message handler.</p>
- <pre class="prettyprint showlinemods notranslate lang-js" translate="no">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 (typeof fn !== 'function') {
- throw new Error('no handler for type: ' + e.data.type);
- }
- fn(e.data);
- };
- </pre>
- <p>In our shared three.js code we need to import the <a href="/docs/#examples/controls/OrbitControls"><code class="notranslate" translate="no">OrbitControls</code></a> and set them up.</p>
- <pre class="prettyprint showlinemods notranslate lang-js" translate="no">import * as THREE from 'three';
- +import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
- export function init(data) {
- - const {canvas} = data;
- + const {canvas, inputElement} = data;
- const renderer = new THREE.WebGLRenderer({antialias: true, canvas});
- + const controls = new OrbitControls(camera, inputElement);
- + controls.target.set(0, 0, 0);
- + controls.update();
- </pre>
- <p>Notice we're passing the OrbitControls our proxy via <code class="notranslate" translate="no">inputElement</code>
- instead of passing in the canvas like we do in other non-OffscreenCanvas
- examples.</p>
- <p>Next we can move all the picking event code from the HTML file
- to the shared three.js code as well while changing
- <code class="notranslate" translate="no">canvas</code> to <code class="notranslate" translate="no">inputElement</code>.</p>
- <pre class="prettyprint showlinemods notranslate lang-js" translate="no">function getCanvasRelativePosition(event) {
- - const rect = canvas.getBoundingClientRect();
- + const rect = inputElement.getBoundingClientRect();
- return {
- x: event.clientX - rect.left,
- y: event.clientY - rect.top,
- };
- }
- function setPickPosition(event) {
- const pos = getCanvasRelativePosition(event);
- - sendMouse(
- - (pos.x / canvas.clientWidth ) * 2 - 1,
- - (pos.y / canvas.clientHeight) * -2 + 1); // note we flip Y
- + pickPosition.x = (pos.x / inputElement.clientWidth ) * 2 - 1;
- + pickPosition.y = (pos.y / 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);
- </pre>
- <p>Back in the main page we need code to send messages for
- all the events we enumerated above.</p>
- <pre class="prettyprint showlinemods notranslate lang-js" translate="no">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);
- });
- }
- }
- }
- </pre>
- <p><code class="notranslate" translate="no">ElementProxy</code> 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 <code class="notranslate" translate="no">makeProxy</code> message we setup earlier. The worker will make
- an <code class="notranslate" translate="no">ElementProxyReceiver</code> and register it to that id.</p>
- <p>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.</p>
- <p>When we start the worker we first make a proxy and pass in our event handlers.</p>
- <pre class="prettyprint showlinemods notranslate lang-js" translate="no">function startWorker(canvas) {
- const offscreen = canvas.transferControlToOffscreen();
- const worker = new Worker('offscreencanvas-worker-orbitcontrols.js', {type: 'module'});
- + const eventHandlers = {
- + contextmenu: preventDefaultHandler,
- + mousedown: mouseEventHandler,
- + mousemove: mouseEventHandler,
- + mouseup: mouseEventHandler,
- + pointerdown: mouseEventHandler,
- + pointermove: mouseEventHandler,
- + pointerup: 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 */
- }
- </pre>
- <p>And here are the event handlers. All they do is copy a list of properties
- from the event they receive. They are passed a <code class="notranslate" translate="no">sendEvent</code> function to which they pass the data
- they make. That function will add the correct id and send it to the worker.</p>
- <pre class="prettyprint showlinemods notranslate lang-js" translate="no">const mouseEventHandler = makeSendPropertiesHandler([
- 'ctrlKey',
- 'metaKey',
- 'shiftKey',
- 'button',
- 'pointerType',
- '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);
- }
- }
- </pre>
- <p>This seems close to running but if we actually try it we'll see
- that the <a href="/docs/#examples/controls/OrbitControls"><code class="notranslate" translate="no">OrbitControls</code></a> need a few more things.</p>
- <p>One is they call <code class="notranslate" translate="no">element.focus</code>. We don't need that to happen
- in the worker so let's just add a stub.</p>
- <pre class="prettyprint showlinemods notranslate lang-js" translate="no">class ElementProxyReceiver extends THREE.EventDispatcher {
- constructor() {
- super();
- }
- handleEvent(data) {
- this.dispatchEvent(data);
- }
- + focus() {
- + // no-op
- + }
- }
- </pre>
- <p>Another is they call <code class="notranslate" translate="no">event.preventDefault</code> and <code class="notranslate" translate="no">event.stopPropagation</code>.
- We're already handling that in the main page so those can also be a noop.</p>
- <pre class="prettyprint showlinemods notranslate lang-js" translate="no">+function noop() {
- +}
- class ElementProxyReceiver extends THREE.EventDispatcher {
- constructor() {
- super();
- }
- handleEvent(data) {
- + data.preventDefault = noop;
- + data.stopPropagation = noop;
- this.dispatchEvent(data);
- }
- focus() {
- // no-op
- }
- }
- </pre>
- <p>Another is they look at <code class="notranslate" translate="no">clientWidth</code> and <code class="notranslate" translate="no">clientHeight</code>. We
- were passing the size before but we can update the proxy pair
- to pass that as well.</p>
- <p>In the worker...</p>
- <pre class="prettyprint showlinemods notranslate lang-js" translate="no">class ElementProxyReceiver extends THREE.EventDispatcher {
- constructor() {
- super();
- }
- + get clientWidth() {
- + return this.width;
- + }
- + get clientHeight() {
- + return this.height;
- + }
- + getBoundingClientRect() {
- + return {
- + left: this.left,
- + top: this.top,
- + width: this.width,
- + height: this.height,
- + right: this.left + this.width,
- + bottom: this.top + this.height,
- + };
- + }
- handleEvent(data) {
- + if (data.type === 'size') {
- + this.left = data.left;
- + this.top = data.top;
- + this.width = data.width;
- + this.height = data.height;
- + return;
- + }
- data.preventDefault = noop;
- data.stopPropagation = noop;
- this.dispatchEvent(data);
- }
- focus() {
- // no-op
- }
- }
- </pre>
- <p>back in the main page we need to send the size and the left and top positions as well.
- Note that as is we don't handle if the canvas moves, only if it resizes. If you wanted
- to handle moving you'd need to call <code class="notranslate" translate="no">sendSize</code> anytime something moved the canvas.</p>
- <pre class="prettyprint showlinemods notranslate lang-js" translate="no">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() {
- + const rect = element.getBoundingClientRect();
- + sendEvent({
- + type: 'size',
- + left: rect.left,
- + top: rect.top,
- + width: element.clientWidth,
- + height: element.clientHeight,
- + });
- + }
- +
- + window.addEventListener('resize', sendSize);
- }
- }
- </pre>
- <p>and in our shared three.js code we no longer need <code class="notranslate" translate="no">state</code></p>
- <pre class="prettyprint showlinemods notranslate lang-js" translate="no">-export 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();
- }
- ...
- </pre>
- <p>A few more hacks. The OrbitControls add <code class="notranslate" translate="no">pointermove</code> and <code class="notranslate" translate="no">pointerup</code> events to the
- <code class="notranslate" translate="no">ownerDocument</code> of the element to handle mouse capture (when the mouse goes
- outside the window).</p>
- <p>Further the code references the global <code class="notranslate" translate="no">document</code> but there is no global document
- in a worker. </p>
- <p>We can solve all of these with a 2 quick hacks. In our worker
- code we'll re-use our proxy for both problems.</p>
- <pre class="prettyprint showlinemods notranslate lang-js" translate="no">function start(data) {
- const proxy = proxyManager.getProxy(data.canvasId);
- + proxy.ownerDocument = proxy; // HACK!
- + self.document = {} // HACK!
- init({
- canvas: data.canvas,
- inputElement: proxy,
- });
- }
- </pre>
- <p>This will give the <a href="/docs/#examples/controls/OrbitControls"><code class="notranslate" translate="no">OrbitControls</code></a> something to inspect which
- matches their expectations.</p>
- <p>I know that was kind of hard to follow. The short version is:
- <code class="notranslate" translate="no">ElementProxy</code> runs on the main page and forwards DOM events
- to <code class="notranslate" translate="no">ElementProxyReceiver</code> in the worker which
- masquerades as an <code class="notranslate" translate="no">HTMLElement</code> that we can use both with the
- <a href="/docs/#examples/controls/OrbitControls"><code class="notranslate" translate="no">OrbitControls</code></a> and with our own code.</p>
- <p>The final thing is our fallback when we are not using OffscreenCanvas.
- All we have to do is pass the canvas itself as our <code class="notranslate" translate="no">inputElement</code>.</p>
- <pre class="prettyprint showlinemods notranslate lang-js" translate="no">function startMainPage(canvas) {
- - init({canvas});
- + init({canvas, inputElement: canvas});
- console.log('using regular canvas');
- }
- </pre>
- <p>and now we should have OrbitControls working with OffscreenCanvas</p>
- <p></p><div translate="no" class="threejs_example_container notranslate">
- <div><iframe class="threejs_example notranslate" translate="no" style=" " src="/manual/examples/resources/editor.html?url=/manual/examples/offscreencanvas-w-orbitcontrols.html"></iframe></div>
- <a class="threejs_center" href="/manual/examples/offscreencanvas-w-orbitcontrols.html" target="_blank">click here to open in a separate window</a>
- </div>
- <p></p>
- <p>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.</p>
- <p>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.</p>
- </div>
- </div>
- </div>
- <script src="../resources/prettify.js"></script>
- <script src="../resources/lesson.js"></script>
- </body></html>
|