offscreencanvas.html 32 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201
  1. Title: Three.js OffscreenCanvas
  2. Description: How to use three.js in a web worker
  3. TOC: Using OffscreenCanvas in a Web Worker
  4. [`OffscreenCanvas`](https://developer.mozilla.org/en-US/docs/Web/API/OffscreenCanvas)
  5. is a relatively new browser feature currently only available in Chrome but apparently
  6. coming to other browsers. `OffscreenCanvas` allows a web worker to render
  7. to a canvas. This is a way to offload heavy work, like rendering a complex 3D scene,
  8. to a web worker so as not to slow down the responsiveness of the browser. It
  9. also means data is loaded and parsed in the worker so possibly less jank while
  10. the page loads.
  11. Getting *started* using it is pretty straight forward. Let's port the 3 spinning cube
  12. example from [the article on responsiveness](threejs-responsive.html).
  13. Workers generally have their code separated
  14. into another script file whereas most of the examples on this site have had
  15. their scripts embedded into the HTML file of the page they are on.
  16. In our case we'll make a file called `offscreencanvas-cubes.js` and
  17. copy all the JavaScript from [the responsive example](threejs-responsive.html) into it. We'll then
  18. make the changes needed for it to run in a worker.
  19. We still need some JavaScript in our HTML file. The first thing
  20. we need to do there is look up the canvas and then transfer control of that
  21. canvas to be offscreen by calling `canvas.transferControlToOffscreen`.
  22. ```js
  23. function main() {
  24. const canvas = document.querySelector('#c');
  25. const offscreen = canvas.transferControlToOffscreen();
  26. ...
  27. ```
  28. We can then start our worker with `new Worker(pathToScript, {type: 'module'})`.
  29. and pass the `offscreen` object to it.
  30. ```js
  31. function main() {
  32. const canvas = document.querySelector('#c');
  33. const offscreen = canvas.transferControlToOffscreen();
  34. const worker = new Worker('offscreencanvas-cubes.js', {type: 'module'});
  35. worker.postMessage({type: 'main', canvas: offscreen}, [offscreen]);
  36. }
  37. main();
  38. ```
  39. It's important to note that workers can't access the `DOM`. They
  40. can't look at HTML elements nor can they receive mouse events or
  41. keyboard events. The only thing they can generally do is respond
  42. to messages sent to them and send messages back to the page.
  43. To send a message to a worker we call [`worker.postMessage`](https://developer.mozilla.org/en-US/docs/Web/API/Worker/postMessage) and
  44. pass it 1 or 2 arguments. The first argument is a JavaScript object
  45. that will be [cloned](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm)
  46. and sent to the worker. The second argument is an optional array
  47. of objects that are part of the first object that we want *transferred*
  48. to the worker. These objects will not be cloned. Instead they will be *transferred*
  49. and will cease to exist in the main page. Cease to exist is the probably
  50. the wrong description, rather they are neutered. Only certain types of
  51. objects can be transferred instead of cloned. They include `OffscreenCanvas`
  52. so once transferred the `offscreen` object back in the main page is useless.
  53. Workers receive messages from their `onmessage` handler. The object
  54. we passed to `postMessage` arrives on `event.data` passed to the `onmessage`
  55. handler on the worker. The code above declares a `type: 'main'` in the object it passes
  56. to the worker. This object has no meaning to the browser. It's entirely for
  57. our own usage. We'll make a handler that based on `type` calls
  58. a different function in the worker. Then we can add functions as
  59. needed and easily call them from the main page.
  60. ```js
  61. const handlers = {
  62. main,
  63. };
  64. self.onmessage = function(e) {
  65. const fn = handlers[e.data.type];
  66. if (!fn) {
  67. throw new Error('no handler for type: ' + e.data.type);
  68. }
  69. fn(e.data);
  70. };
  71. ```
  72. You can see above we just look up the handler based on the `type` pass it the `data`
  73. that was sent from the main page.
  74. So now we just need to start changing the `main` we pasted into
  75. `offscreencanvas-cubes.js` from [the responsive article](threejs-responsive.html).
  76. Instead of looking up the canvas from the DOM we'll receive it from the
  77. event data.
  78. ```js
  79. -function main() {
  80. - const canvas = document.querySelector('#c');
  81. +function main(data) {
  82. + const {canvas} = data;
  83. const renderer = new THREE.WebGLRenderer({canvas});
  84. ...
  85. ```
  86. Remembering that workers can't see the DOM at all the first problem
  87. we run into is `resizeRendererToDisplaySize` can't look at `canvas.clientWidth`
  88. and `canvas.clientHeight` as those are DOM values. Here's the original code
  89. ```js
  90. function resizeRendererToDisplaySize(renderer) {
  91. const canvas = renderer.domElement;
  92. const width = canvas.clientWidth;
  93. const height = canvas.clientHeight;
  94. const needResize = canvas.width !== width || canvas.height !== height;
  95. if (needResize) {
  96. renderer.setSize(width, height, false);
  97. }
  98. return needResize;
  99. }
  100. ```
  101. Instead we'll need to send sizes as they change to the worker.
  102. So, let's add some global state and keep the width and height there.
  103. ```js
  104. const state = {
  105. width: 300, // canvas default
  106. height: 150, // canvas default
  107. };
  108. ```
  109. Then let's add a `'size'` handler to update those values.
  110. ```js
  111. +function size(data) {
  112. + state.width = data.width;
  113. + state.height = data.height;
  114. +}
  115. const handlers = {
  116. main,
  117. + size,
  118. };
  119. ```
  120. Now we can change `resizeRendererToDisplaySize` to use `state.width` and `state.height`
  121. ```js
  122. function resizeRendererToDisplaySize(renderer) {
  123. const canvas = renderer.domElement;
  124. - const width = canvas.clientWidth;
  125. - const height = canvas.clientHeight;
  126. + const width = state.width;
  127. + const height = state.height;
  128. const needResize = canvas.width !== width || canvas.height !== height;
  129. if (needResize) {
  130. renderer.setSize(width, height, false);
  131. }
  132. return needResize;
  133. }
  134. ```
  135. and where we compute the aspect we need similar changes
  136. ```js
  137. function render(time) {
  138. time *= 0.001;
  139. if (resizeRendererToDisplaySize(renderer)) {
  140. - camera.aspect = canvas.clientWidth / canvas.clientHeight;
  141. + camera.aspect = state.width / state.height;
  142. camera.updateProjectionMatrix();
  143. }
  144. ...
  145. ```
  146. Back in the main page we'll send a `size` event anytime the page changes size.
  147. ```js
  148. const worker = new Worker('offscreencanvas-picking.js', {type: 'module'});
  149. worker.postMessage({type: 'main', canvas: offscreen}, [offscreen]);
  150. +function sendSize() {
  151. + worker.postMessage({
  152. + type: 'size',
  153. + width: canvas.clientWidth,
  154. + height: canvas.clientHeight,
  155. + });
  156. +}
  157. +
  158. +window.addEventListener('resize', sendSize);
  159. +sendSize();
  160. ```
  161. We also call it once to send the initial size.
  162. And with just those few changes, assuming your browser fully supports `OffscreenCanvas`
  163. it should work. Before we run it though let's check if the browser actually supports
  164. `OffscreenCanvas` and if not display an error. First let's add some HTML to display the error.
  165. ```html
  166. <body>
  167. <canvas id="c"></canvas>
  168. + <div id="noOffscreenCanvas" style="display:none;">
  169. + <div>no OffscreenCanvas support</div>
  170. + </div>
  171. </body>
  172. ```
  173. and some CSS for that
  174. ```css
  175. #noOffscreenCanvas {
  176. display: flex;
  177. width: 100%;
  178. height: 100%;
  179. align-items: center;
  180. justify-content: center;
  181. background: red;
  182. color: white;
  183. }
  184. ```
  185. and then we can check for the existence of `transferControlToOffscreen` to see
  186. if the browser supports `OffscreenCanvas`
  187. ```js
  188. function main() {
  189. const canvas = document.querySelector('#c');
  190. + if (!canvas.transferControlToOffscreen) {
  191. + canvas.style.display = 'none';
  192. + document.querySelector('#noOffscreenCanvas').style.display = '';
  193. + return;
  194. + }
  195. const offscreen = canvas.transferControlToOffscreen();
  196. const worker = new Worker('offscreencanvas-picking.js', {type: 'module});
  197. worker.postMessage({type: 'main', canvas: offscreen}, [offscreen]);
  198. ...
  199. ```
  200. and with that, if your browser supports `OffscreenCanvas` this example should work
  201. {{{example url="../threejs-offscreencanvas.html" }}}
  202. So that's great but since not every browser supports `OffscreenCanvas` at the moment
  203. let's change the code to work with both `OffscreenCanvas` and if not then fallback to using
  204. the canvas in the main page like normal.
  205. > As an aside, if you need OffscreenCanvas to make your page responsive then
  206. > it's not clear what the point of having a fallback is. Maybe based on if
  207. > you end up running on the main page or in a worker you might adjust the amount
  208. > of work done so that when running in a worker you can do more than when
  209. > running in the main page. What you do is really up to you.
  210. The first thing we should probably do is separate out the three.js
  211. code from the code that is specific to the worker. That way we can
  212. use the same code on both the main page and the worker. In other words
  213. we will now have 3 files
  214. 1. our html file.
  215. `threejs-offscreencanvas-w-fallback.html`
  216. 2. a JavaScript that contains our three.js code.
  217. `shared-cubes.js`
  218. 3. our worker support code
  219. `offscreencanvas-worker-cubes.js`
  220. `shared-cubes.js` and `offscreencanvas-worker-cubes.js` are basically
  221. the split of our previous `offscreencanvas-cubes.js` file. First we
  222. copy all of `offscreencanvas-cubes.js` to `shared-cube.js`. Then
  223. we rename `main` to `init` since we already have a `main` in our
  224. HTML file and we need to export `init` and `state`
  225. ```js
  226. import * as THREE from './resources/threejs/r132/build/three.module.js';
  227. -const state = {
  228. +export const state = {
  229. width: 300, // canvas default
  230. height: 150, // canvas default
  231. };
  232. -function main(data) {
  233. +export function init(data) {
  234. const {canvas} = data;
  235. const renderer = new THREE.WebGLRenderer({canvas});
  236. ```
  237. and cut out the just the non three.js relates parts
  238. ```js
  239. -function size(data) {
  240. - state.width = data.width;
  241. - state.height = data.height;
  242. -}
  243. -
  244. -const handlers = {
  245. - main,
  246. - size,
  247. -};
  248. -
  249. -self.onmessage = function(e) {
  250. - const fn = handlers[e.data.type];
  251. - if (!fn) {
  252. - throw new Error('no handler for type: ' + e.data.type);
  253. - }
  254. - fn(e.data);
  255. -};
  256. ```
  257. Then we copy those parts we just deleted to `offscreencanvas-worker-cubes.js`
  258. and import `shared-cubes.js` as well as call `init` instead of `main`.
  259. ```js
  260. import {init, state} from './shared-cubes.js';
  261. function size(data) {
  262. state.width = data.width;
  263. state.height = data.height;
  264. }
  265. const handlers = {
  266. - main,
  267. + init,
  268. size,
  269. };
  270. self.onmessage = function(e) {
  271. const fn = handlers[e.data.type];
  272. if (!fn) {
  273. throw new Error('no handler for type: ' + e.data.type);
  274. }
  275. fn(e.data);
  276. };
  277. ```
  278. Similarly we need to include `shared-cubes.js` in the main page
  279. ```html
  280. <script type="module">
  281. +import {init, state} from './shared-cubes.js';
  282. ```
  283. We can remove the HTML and CSS we added previously
  284. ```html
  285. <body>
  286. <canvas id="c"></canvas>
  287. - <div id="noOffscreenCanvas" style="display:none;">
  288. - <div>no OffscreenCanvas support</div>
  289. - </div>
  290. </body>
  291. ```
  292. and some CSS for that
  293. ```css
  294. -#noOffscreenCanvas {
  295. - display: flex;
  296. - width: 100%;
  297. - height: 100%;
  298. - align-items: center;
  299. - justify-content: center;
  300. - background: red;
  301. - color: white;
  302. -}
  303. ```
  304. Then let's change the code in the main page to call one start
  305. function or another depending on if the browser supports `OffscreenCanvas`.
  306. ```js
  307. function main() {
  308. const canvas = document.querySelector('#c');
  309. - if (!canvas.transferControlToOffscreen) {
  310. - canvas.style.display = 'none';
  311. - document.querySelector('#noOffscreenCanvas').style.display = '';
  312. - return;
  313. - }
  314. - const offscreen = canvas.transferControlToOffscreen();
  315. - const worker = new Worker('offscreencanvas-picking.js', {type: 'module'});
  316. - worker.postMessage({type: 'main', canvas: offscreen}, [offscreen]);
  317. + if (canvas.transferControlToOffscreen) {
  318. + startWorker(canvas);
  319. + } else {
  320. + startMainPage(canvas);
  321. + }
  322. ...
  323. ```
  324. We'll move all the code we had to setup the worker inside `startWorker`
  325. ```js
  326. function startWorker(canvas) {
  327. const offscreen = canvas.transferControlToOffscreen();
  328. const worker = new Worker('offscreencanvas-worker-cubes.js', {type: 'module'});
  329. worker.postMessage({type: 'main', canvas: offscreen}, [offscreen]);
  330. function sendSize() {
  331. worker.postMessage({
  332. type: 'size',
  333. width: canvas.clientWidth,
  334. height: canvas.clientHeight,
  335. });
  336. }
  337. window.addEventListener('resize', sendSize);
  338. sendSize();
  339. console.log('using OffscreenCanvas');
  340. }
  341. ```
  342. and send `init` instead of `main`
  343. ```js
  344. - worker.postMessage({type: 'main', canvas: offscreen}, [offscreen]);
  345. + worker.postMessage({type: 'init', canvas: offscreen}, [offscreen]);
  346. ```
  347. for starting in the main page we can do this
  348. ```js
  349. function startMainPage(canvas) {
  350. init({canvas});
  351. function sendSize() {
  352. state.width = canvas.clientWidth;
  353. state.height = canvas.clientHeight;
  354. }
  355. window.addEventListener('resize', sendSize);
  356. sendSize();
  357. console.log('using regular canvas');
  358. }
  359. ```
  360. and with that our example will run either in an OffscreenCanvas or
  361. fallback to running in the main page.
  362. {{{example url="../threejs-offscreencanvas-w-fallback.html" }}}
  363. So that was relatively easy. Let's try picking. We'll take some code from
  364. the `RayCaster` example from [the article on picking](threejs-picking.html)
  365. and make it work offscreen.
  366. Let's copy the `shared-cube.js` to `shared-picking.js` and add the
  367. picking parts. We copy in the `PickHelper`
  368. ```js
  369. class PickHelper {
  370. constructor() {
  371. this.raycaster = new THREE.Raycaster();
  372. this.pickedObject = null;
  373. this.pickedObjectSavedColor = 0;
  374. }
  375. pick(normalizedPosition, scene, camera, time) {
  376. // restore the color if there is a picked object
  377. if (this.pickedObject) {
  378. this.pickedObject.material.emissive.setHex(this.pickedObjectSavedColor);
  379. this.pickedObject = undefined;
  380. }
  381. // cast a ray through the frustum
  382. this.raycaster.setFromCamera(normalizedPosition, camera);
  383. // get the list of objects the ray intersected
  384. const intersectedObjects = this.raycaster.intersectObjects(scene.children);
  385. if (intersectedObjects.length) {
  386. // pick the first object. It's the closest one
  387. this.pickedObject = intersectedObjects[0].object;
  388. // save its color
  389. this.pickedObjectSavedColor = this.pickedObject.material.emissive.getHex();
  390. // set its emissive color to flashing red/yellow
  391. this.pickedObject.material.emissive.setHex((time * 8) % 2 > 1 ? 0xFFFF00 : 0xFF0000);
  392. }
  393. }
  394. }
  395. const pickPosition = {x: 0, y: 0};
  396. const pickHelper = new PickHelper();
  397. ```
  398. We updated `pickPosition` from the mouse like this
  399. ```js
  400. function getCanvasRelativePosition(event) {
  401. const rect = canvas.getBoundingClientRect();
  402. return {
  403. x: (event.clientX - rect.left) * canvas.width / rect.width,
  404. y: (event.clientY - rect.top ) * canvas.height / rect.height,
  405. };
  406. }
  407. function setPickPosition(event) {
  408. const pos = getCanvasRelativePosition(event);
  409. pickPosition.x = (pos.x / canvas.width ) * 2 - 1;
  410. pickPosition.y = (pos.y / canvas.height) * -2 + 1; // note we flip Y
  411. }
  412. window.addEventListener('mousemove', setPickPosition);
  413. ```
  414. A worker can't read the mouse position directly so just like the size code
  415. let's send a message with the mouse position. Like the size code we'll
  416. send the mouse position and update `pickPosition`
  417. ```js
  418. function size(data) {
  419. state.width = data.width;
  420. state.height = data.height;
  421. }
  422. +function mouse(data) {
  423. + pickPosition.x = data.x;
  424. + pickPosition.y = data.y;
  425. +}
  426. const handlers = {
  427. init,
  428. + mouse,
  429. size,
  430. };
  431. self.onmessage = function(e) {
  432. const fn = handlers[e.data.type];
  433. if (!fn) {
  434. throw new Error('no handler for type: ' + e.data.type);
  435. }
  436. fn(e.data);
  437. };
  438. ```
  439. Back in our main page we need to add code to pass the mouse
  440. to the worker or the main page.
  441. ```js
  442. +let sendMouse;
  443. function startWorker(canvas) {
  444. const offscreen = canvas.transferControlToOffscreen();
  445. const worker = new Worker('offscreencanvas-worker-picking.js', {type: 'module'});
  446. worker.postMessage({type: 'init', canvas: offscreen}, [offscreen]);
  447. + sendMouse = (x, y) => {
  448. + worker.postMessage({
  449. + type: 'mouse',
  450. + x,
  451. + y,
  452. + });
  453. + };
  454. function sendSize() {
  455. worker.postMessage({
  456. type: 'size',
  457. width: canvas.clientWidth,
  458. height: canvas.clientHeight,
  459. });
  460. }
  461. window.addEventListener('resize', sendSize);
  462. sendSize();
  463. console.log('using OffscreenCanvas'); /* eslint-disable-line no-console */
  464. }
  465. function startMainPage(canvas) {
  466. init({canvas});
  467. + sendMouse = (x, y) => {
  468. + pickPosition.x = x;
  469. + pickPosition.y = y;
  470. + };
  471. function sendSize() {
  472. state.width = canvas.clientWidth;
  473. state.height = canvas.clientHeight;
  474. }
  475. window.addEventListener('resize', sendSize);
  476. sendSize();
  477. console.log('using regular canvas'); /* eslint-disable-line no-console */
  478. }
  479. ```
  480. Then we can copy in all the mouse handling code to the main page and
  481. make just minor changes to use `sendMouse`
  482. ```js
  483. function setPickPosition(event) {
  484. const pos = getCanvasRelativePosition(event);
  485. - pickPosition.x = (pos.x / canvas.clientWidth ) * 2 - 1;
  486. - pickPosition.y = (pos.y / canvas.clientHeight) * -2 + 1; // note we flip Y
  487. + sendMouse(
  488. + (pos.x / canvas.clientWidth ) * 2 - 1,
  489. + (pos.y / canvas.clientHeight) * -2 + 1); // note we flip Y
  490. }
  491. function clearPickPosition() {
  492. // unlike the mouse which always has a position
  493. // if the user stops touching the screen we want
  494. // to stop picking. For now we just pick a value
  495. // unlikely to pick something
  496. - pickPosition.x = -100000;
  497. - pickPosition.y = -100000;
  498. + sendMouse(-100000, -100000);
  499. }
  500. window.addEventListener('mousemove', setPickPosition);
  501. window.addEventListener('mouseout', clearPickPosition);
  502. window.addEventListener('mouseleave', clearPickPosition);
  503. window.addEventListener('touchstart', (event) => {
  504. // prevent the window from scrolling
  505. event.preventDefault();
  506. setPickPosition(event.touches[0]);
  507. }, {passive: false});
  508. window.addEventListener('touchmove', (event) => {
  509. setPickPosition(event.touches[0]);
  510. });
  511. window.addEventListener('touchend', clearPickPosition);
  512. ```
  513. and with that picking should be working with `OffscreenCanvas`.
  514. {{{example url="../threejs-offscreencanvas-w-picking.html" }}}
  515. Let's take it one more step and add in the `OrbitControls`.
  516. This will be little more involved. The `OrbitControls` use
  517. the DOM pretty extensively checking the mouse, touch events,
  518. and the keyboard.
  519. Unlike our code so far we can't really use a global `state` object
  520. without re-writing all the OrbitControls code to work with it.
  521. The OrbitControls take an `HTMLElement` to which they attach most
  522. of the DOM events they use. Maybe we could pass in our own
  523. object that has the same API surface as a DOM element.
  524. We only need to support the features the OrbitControls need.
  525. Digging through the [OrbitControls source code](https://github.com/gfxfundamentals/threejsfundamentals/blob/master/threejs/resources/threejs/r132/examples/js/controls/OrbitControls.js)
  526. it looks like we need to handle the following events.
  527. * contextmenu
  528. * pointerdown
  529. * pointermove
  530. * pointerup
  531. * touchstart
  532. * touchmove
  533. * touchend
  534. * wheel
  535. * keydown
  536. For the pointer events we need the `ctrlKey`, `metaKey`, `shiftKey`,
  537. `button`, `pointerType`, `clientX`, `clientY`, `pageX`, and `pageY`, properties.
  538. For the keydown events we need the `ctrlKey`, `metaKey`, `shiftKey`,
  539. and `keyCode` properties.
  540. For the wheel event we only need the `deltaY` property.
  541. And for the touch events we only need `pageX` and `pageY` from
  542. the `touches` property.
  543. So, let's make a proxy object pair. One part will run in the main page,
  544. get all those events, and pass on the relevant property values
  545. to the worker. The other part will run in the worker, receive those
  546. events and pass them on using events that have the same structure
  547. as the original DOM events so the OrbitControls won't be able to
  548. tell the difference.
  549. Here's the code for the worker part.
  550. ```js
  551. import {EventDispatcher} from './resources/threejs/r132/build/three.module.js';
  552. class ElementProxyReceiver extends EventDispatcher {
  553. constructor() {
  554. super();
  555. }
  556. handleEvent(data) {
  557. this.dispatchEvent(data);
  558. }
  559. }
  560. ```
  561. All it does is if it receives a message it dispatches it.
  562. It inherits from `EventDispatcher` which provides methods like
  563. `addEventListener` and `removeEventListener` just like a DOM
  564. element so if we pass it to the OrbitControls it should work.
  565. `ElementProxyReceiver` handles 1 element. In our case we only need
  566. one but it's best to think head so lets make a manager to manage
  567. more than one of them.
  568. ```js
  569. class ProxyManager {
  570. constructor() {
  571. this.targets = {};
  572. this.handleEvent = this.handleEvent.bind(this);
  573. }
  574. makeProxy(data) {
  575. const {id} = data;
  576. const proxy = new ElementProxyReceiver();
  577. this.targets[id] = proxy;
  578. }
  579. getProxy(id) {
  580. return this.targets[id];
  581. }
  582. handleEvent(data) {
  583. this.targets[data.id].handleEvent(data.data);
  584. }
  585. }
  586. ```
  587. We can make a instance of `ProxyManager` and call its `makeProxy`
  588. method with an id which will make an `ElementProxyReceiver` that
  589. responds to messages with that id.
  590. Let's hook it up to our worker's message handler.
  591. ```js
  592. const proxyManager = new ProxyManager();
  593. function start(data) {
  594. const proxy = proxyManager.getProxy(data.canvasId);
  595. init({
  596. canvas: data.canvas,
  597. inputElement: proxy,
  598. });
  599. }
  600. function makeProxy(data) {
  601. proxyManager.makeProxy(data);
  602. }
  603. ...
  604. const handlers = {
  605. - init,
  606. - mouse,
  607. + start,
  608. + makeProxy,
  609. + event: proxyManager.handleEvent,
  610. size,
  611. };
  612. self.onmessage = function(e) {
  613. const fn = handlers[e.data.type];
  614. if (!fn) {
  615. throw new Error('no handler for type: ' + e.data.type);
  616. }
  617. fn(e.data);
  618. };
  619. ```
  620. In our shared three.js code we need to import the `OrbitControls` and set them up.
  621. ```js
  622. import * as THREE from './resources/threejs/r132/build/three.module.js';
  623. +import {OrbitControls} from './resources/threejs/r132/examples/jsm/controls/OrbitControls.js';
  624. export function init(data) {
  625. - const {canvas} = data;
  626. + const {canvas, inputElement} = data;
  627. const renderer = new THREE.WebGLRenderer({canvas});
  628. + const controls = new OrbitControls(camera, inputElement);
  629. + controls.target.set(0, 0, 0);
  630. + controls.update();
  631. ```
  632. Notice we're passing the OrbitControls our proxy via `inputElement`
  633. instead of passing in the canvas like we do in other non-OffscreenCanvas
  634. examples.
  635. Next we can move all the picking event code from the HTML file
  636. to the shared three.js code as well while changing
  637. `canvas` to `inputElement`.
  638. ```js
  639. function getCanvasRelativePosition(event) {
  640. - const rect = canvas.getBoundingClientRect();
  641. + const rect = inputElement.getBoundingClientRect();
  642. return {
  643. x: event.clientX - rect.left,
  644. y: event.clientY - rect.top,
  645. };
  646. }
  647. function setPickPosition(event) {
  648. const pos = getCanvasRelativePosition(event);
  649. - sendMouse(
  650. - (pos.x / canvas.clientWidth ) * 2 - 1,
  651. - (pos.y / canvas.clientHeight) * -2 + 1); // note we flip Y
  652. + pickPosition.x = (pos.x / inputElement.clientWidth ) * 2 - 1;
  653. + pickPosition.y = (pos.y / inputElement.clientHeight) * -2 + 1; // note we flip Y
  654. }
  655. function clearPickPosition() {
  656. // unlike the mouse which always has a position
  657. // if the user stops touching the screen we want
  658. // to stop picking. For now we just pick a value
  659. // unlikely to pick something
  660. - sendMouse(-100000, -100000);
  661. + pickPosition.x = -100000;
  662. + pickPosition.y = -100000;
  663. }
  664. *inputElement.addEventListener('mousemove', setPickPosition);
  665. *inputElement.addEventListener('mouseout', clearPickPosition);
  666. *inputElement.addEventListener('mouseleave', clearPickPosition);
  667. *inputElement.addEventListener('touchstart', (event) => {
  668. // prevent the window from scrolling
  669. event.preventDefault();
  670. setPickPosition(event.touches[0]);
  671. }, {passive: false});
  672. *inputElement.addEventListener('touchmove', (event) => {
  673. setPickPosition(event.touches[0]);
  674. });
  675. *inputElement.addEventListener('touchend', clearPickPosition);
  676. ```
  677. Back in the main page we need code to send messages for
  678. all the events we enumerated above.
  679. ```js
  680. let nextProxyId = 0;
  681. class ElementProxy {
  682. constructor(element, worker, eventHandlers) {
  683. this.id = nextProxyId++;
  684. this.worker = worker;
  685. const sendEvent = (data) => {
  686. this.worker.postMessage({
  687. type: 'event',
  688. id: this.id,
  689. data,
  690. });
  691. };
  692. // register an id
  693. worker.postMessage({
  694. type: 'makeProxy',
  695. id: this.id,
  696. });
  697. for (const [eventName, handler] of Object.entries(eventHandlers)) {
  698. element.addEventListener(eventName, function(event) {
  699. handler(event, sendEvent);
  700. });
  701. }
  702. }
  703. }
  704. ```
  705. `ElementProxy` takes the element who's events we want to proxy. It
  706. then registers an id with the worker by picking one and sending it
  707. via the `makeProxy` message we setup earlier. The worker will make
  708. an `ElementProxyReceiver` and register it to that id.
  709. We then have an object of event handlers to register. This way
  710. we can pass handlers only for these events we want to forward to
  711. the worker.
  712. When we start the worker we first make a proxy and pass in our event handlers.
  713. ```js
  714. function startWorker(canvas) {
  715. const offscreen = canvas.transferControlToOffscreen();
  716. const worker = new Worker('offscreencanvas-worker-orbitcontrols.js', {type: 'module'});
  717. + const eventHandlers = {
  718. + contextmenu: preventDefaultHandler,
  719. + mousedown: mouseEventHandler,
  720. + mousemove: mouseEventHandler,
  721. + mouseup: mouseEventHandler,
  722. + pointerdown: mouseEventHandler,
  723. + pointermove: mouseEventHandler,
  724. + pointerup: mouseEventHandler,
  725. + touchstart: touchEventHandler,
  726. + touchmove: touchEventHandler,
  727. + touchend: touchEventHandler,
  728. + wheel: wheelEventHandler,
  729. + keydown: filteredKeydownEventHandler,
  730. + };
  731. + const proxy = new ElementProxy(canvas, worker, eventHandlers);
  732. worker.postMessage({
  733. type: 'start',
  734. canvas: offscreen,
  735. + canvasId: proxy.id,
  736. }, [offscreen]);
  737. console.log('using OffscreenCanvas'); /* eslint-disable-line no-console */
  738. }
  739. ```
  740. And here are the event handlers. All they do is copy a list of properties
  741. from the event they receive. They are passed a `sendEvent` function to which they pass the data
  742. they make. That function will add the correct id and send it to the worker.
  743. ```js
  744. const mouseEventHandler = makeSendPropertiesHandler([
  745. 'ctrlKey',
  746. 'metaKey',
  747. 'shiftKey',
  748. 'button',
  749. 'pointerType',
  750. 'clientX',
  751. 'clientY',
  752. 'pageX',
  753. 'pageY',
  754. ]);
  755. const wheelEventHandlerImpl = makeSendPropertiesHandler([
  756. 'deltaX',
  757. 'deltaY',
  758. ]);
  759. const keydownEventHandler = makeSendPropertiesHandler([
  760. 'ctrlKey',
  761. 'metaKey',
  762. 'shiftKey',
  763. 'keyCode',
  764. ]);
  765. function wheelEventHandler(event, sendFn) {
  766. event.preventDefault();
  767. wheelEventHandlerImpl(event, sendFn);
  768. }
  769. function preventDefaultHandler(event) {
  770. event.preventDefault();
  771. }
  772. function copyProperties(src, properties, dst) {
  773. for (const name of properties) {
  774. dst[name] = src[name];
  775. }
  776. }
  777. function makeSendPropertiesHandler(properties) {
  778. return function sendProperties(event, sendFn) {
  779. const data = {type: event.type};
  780. copyProperties(event, properties, data);
  781. sendFn(data);
  782. };
  783. }
  784. function touchEventHandler(event, sendFn) {
  785. const touches = [];
  786. const data = {type: event.type, touches};
  787. for (let i = 0; i < event.touches.length; ++i) {
  788. const touch = event.touches[i];
  789. touches.push({
  790. pageX: touch.pageX,
  791. pageY: touch.pageY,
  792. });
  793. }
  794. sendFn(data);
  795. }
  796. // The four arrow keys
  797. const orbitKeys = {
  798. '37': true, // left
  799. '38': true, // up
  800. '39': true, // right
  801. '40': true, // down
  802. };
  803. function filteredKeydownEventHandler(event, sendFn) {
  804. const {keyCode} = event;
  805. if (orbitKeys[keyCode]) {
  806. event.preventDefault();
  807. keydownEventHandler(event, sendFn);
  808. }
  809. }
  810. ```
  811. This seems close to running but if we actually try it we'll see
  812. that the `OrbitControls` need a few more things.
  813. One is they call `element.focus`. We don't need that to happen
  814. in the worker so let's just add a stub.
  815. ```js
  816. class ElementProxyReceiver extends THREE.EventDispatcher {
  817. constructor() {
  818. super();
  819. }
  820. handleEvent(data) {
  821. this.dispatchEvent(data);
  822. }
  823. + focus() {
  824. + // no-op
  825. + }
  826. }
  827. ```
  828. Another is they call `event.preventDefault` and `event.stopPropagation`.
  829. We're already handling that in the main page so those can also be a noop.
  830. ```js
  831. +function noop() {
  832. +}
  833. class ElementProxyReceiver extends THREE.EventDispatcher {
  834. constructor() {
  835. super();
  836. }
  837. handleEvent(data) {
  838. + data.preventDefault = noop;
  839. + data.stopPropagation = noop;
  840. this.dispatchEvent(data);
  841. }
  842. focus() {
  843. // no-op
  844. }
  845. }
  846. ```
  847. Another is they look at `clientWidth` and `clientHeight`. We
  848. were passing the size before but we can update the proxy pair
  849. to pass that as well.
  850. In the worker...
  851. ```js
  852. class ElementProxyReceiver extends THREE.EventDispatcher {
  853. constructor() {
  854. super();
  855. }
  856. + get clientWidth() {
  857. + return this.width;
  858. + }
  859. + get clientHeight() {
  860. + return this.height;
  861. + }
  862. + getBoundingClientRect() {
  863. + return {
  864. + left: this.left,
  865. + top: this.top,
  866. + width: this.width,
  867. + height: this.height,
  868. + right: this.left + this.width,
  869. + bottom: this.top + this.height,
  870. + };
  871. + }
  872. handleEvent(data) {
  873. + if (data.type === 'size') {
  874. + this.left = data.left;
  875. + this.top = data.top;
  876. + this.width = data.width;
  877. + this.height = data.height;
  878. + return;
  879. + }
  880. data.preventDefault = noop;
  881. data.stopPropagation = noop;
  882. this.dispatchEvent(data);
  883. }
  884. focus() {
  885. // no-op
  886. }
  887. }
  888. ```
  889. back in the main page we need to send the size and the left and top positions as well.
  890. Note that as is we don't handle if the canvas moves, only if it resizes. If you wanted
  891. to handle moving you'd need to call `sendSize` anytime something moved the canvas.
  892. ```js
  893. class ElementProxy {
  894. constructor(element, worker, eventHandlers) {
  895. this.id = nextProxyId++;
  896. this.worker = worker;
  897. const sendEvent = (data) => {
  898. this.worker.postMessage({
  899. type: 'event',
  900. id: this.id,
  901. data,
  902. });
  903. };
  904. // register an id
  905. worker.postMessage({
  906. type: 'makeProxy',
  907. id: this.id,
  908. });
  909. + sendSize();
  910. for (const [eventName, handler] of Object.entries(eventHandlers)) {
  911. element.addEventListener(eventName, function(event) {
  912. handler(event, sendEvent);
  913. });
  914. }
  915. + function sendSize() {
  916. + const rect = element.getBoundingClientRect();
  917. + sendEvent({
  918. + type: 'size',
  919. + left: rect.left,
  920. + top: rect.top,
  921. + width: element.clientWidth,
  922. + height: element.clientHeight,
  923. + });
  924. + }
  925. +
  926. + window.addEventListener('resize', sendSize);
  927. }
  928. }
  929. ```
  930. and in our shared three.js code we no longer need `state`
  931. ```js
  932. -export const state = {
  933. - width: 300, // canvas default
  934. - height: 150, // canvas default
  935. -};
  936. ...
  937. function resizeRendererToDisplaySize(renderer) {
  938. const canvas = renderer.domElement;
  939. - const width = state.width;
  940. - const height = state.height;
  941. + const width = inputElement.clientWidth;
  942. + const height = inputElement.clientHeight;
  943. const needResize = canvas.width !== width || canvas.height !== height;
  944. if (needResize) {
  945. renderer.setSize(width, height, false);
  946. }
  947. return needResize;
  948. }
  949. function render(time) {
  950. time *= 0.001;
  951. if (resizeRendererToDisplaySize(renderer)) {
  952. - camera.aspect = state.width / state.height;
  953. + camera.aspect = inputElement.clientWidth / inputElement.clientHeight;
  954. camera.updateProjectionMatrix();
  955. }
  956. ...
  957. ```
  958. A few more hacks. The OrbitControls add `pointermove` and `pointerup` events to the
  959. `ownerDocument` of the element to handle mouse capture (when the mouse goes
  960. outside the window).
  961. Further the code references the global `document` but there is no global document
  962. in a worker.
  963. We can solve all of these with a 2 quick hacks. In our worker
  964. code we'll re-use our proxy for both problems.
  965. ```js
  966. function start(data) {
  967. const proxy = proxyManager.getProxy(data.canvasId);
  968. + proxy.ownerDocument = proxy; // HACK!
  969. + self.document = {} // HACK!
  970. init({
  971. canvas: data.canvas,
  972. inputElement: proxy,
  973. });
  974. }
  975. ```
  976. This will give the `OrbitControls` something to inspect which
  977. matches their expectations.
  978. I know that was kind of hard to follow. The short version is:
  979. `ElementProxy` runs on the main page and forwards DOM events
  980. to `ElementProxyReceiver` in the worker which
  981. masquerades as an `HTMLElement` that we can use both with the
  982. `OrbitControls` and with our own code.
  983. The final thing is our fallback when we are not using OffscreenCanvas.
  984. All we have to do is pass the canvas itself as our `inputElement`.
  985. ```js
  986. function startMainPage(canvas) {
  987. - init({canvas});
  988. + init({canvas, inputElement: canvas});
  989. console.log('using regular canvas');
  990. }
  991. ```
  992. and now we should have OrbitControls working with OffscreenCanvas
  993. {{{example url="../threejs-offscreencanvas-w-orbitcontrols.html" }}}
  994. This is probably the most complicated example on this site. It's a
  995. little hard to follow because there are 3 files involved for each
  996. sample. The HTML file, the worker file, the shared three.js code.
  997. I hope it wasn't too difficult to understand and that it provided some
  998. useful examples of working with three.js, OffscreenCanvas and web workers.
粤ICP备19079148号