# OffscreenCanvas

`OffscreenCanvas` est une fonctionnalité de navigateur relativement nouvelle, actuellement disponible uniquement dans Chrome mais apparemment à venir sur d'autres navigateurs. `OffscreenCanvas` permet à un web worker de rendre sur un canevas. C'est une façon de décharger le travail lourd, comme le rendu d'une scène 3D complexe, sur un web worker afin de ne pas ralentir la réactivité du navigateur. Cela signifie également que les données sont chargées et analysées dans le worker, ce qui réduit potentiellement les saccades pendant le chargement de la page.

Commencer à l'utiliser est assez simple. Portons l'exemple des 3 cubes en rotation depuis l'article sur la réactivité.

En général, les workers ont leur code séparé dans un autre fichier script, tandis que la plupart des exemples sur ce site ont leurs scripts intégrés dans le fichier HTML de la page sur laquelle ils se trouvent.

Dans notre cas, nous allons créer un fichier appelé `offscreencanvas-cubes.js` et y copier tout le JavaScript depuis l'exemple réactif. Nous apporterons ensuite les modifications nécessaires pour qu'il s'exécute dans un worker.

Nous avons encore besoin de JavaScript dans notre fichier HTML. La première chose à faire est de trouver le canevas, puis de transférer son contrôle pour qu'il soit offscreen en appelant `canvas.transferControlToOffscreen`.

    function main() {
      const canvas = document.querySelector('#c');
      const offscreen = canvas.transferControlToOffscreen();

      ...

Nous pouvons ensuite démarrer notre worker avec `new Worker(pathToScript, {type: 'module'})`. et lui passer l'objet `offscreen`.

    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();

Il est important de noter que les workers ne peuvent pas accéder au `DOM`. Ils ne peuvent pas regarder les éléments HTML ni recevoir les événements de souris ou de clavier. La seule chose qu'ils peuvent généralement faire est de répondre aux messages qui leur sont envoyés et de renvoyer des messages à la page.

Pour envoyer un message à un worker, nous appelons `worker.postMessage` et lui passons 1 ou 2 arguments. Le premier argument est un objet JavaScript qui sera cloné et envoyé au worker. Le second argument est un tableau optionnel d'objets qui font partie du premier objet et que nous voulons transférer au worker. Ces objets ne seront pas clonés. Au lieu de cela, ils seront transférés et cesseront d'exister dans la page principale. Cesser d'exister est probablement la mauvaise description, ils sont plutôt neutralisés. Seuls certains types d'objets peuvent être transférés au lieu d'être clonés. Ils incluent `OffscreenCanvas`, donc une fois transféré, l'objet `offscreen` dans la page principale devient inutile.

Les workers reçoivent les messages via leur gestionnaire `onmessage`. L'objet que nous avons passé à `postMessage` arrive sur `event.data` passé au gestionnaire `onmessage` sur le worker. Le code ci-dessus déclare un `type: 'main'` dans l'objet qu'il passe au worker. Cet objet n'a aucune signification pour le navigateur. Il est entièrement destiné à notre propre usage. Nous allons créer un gestionnaire qui, basé sur le `type`, appelle une fonction différente dans le worker. Ensuite, nous pourrons ajouter des fonctions au besoin et les appeler facilement depuis la page principale.

    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);
    };

Vous pouvez voir ci-dessus que nous recherchons simplement le gestionnaire basé sur le `type` et que nous lui passons les `data` qui ont été envoyées depuis la page principale.

Il ne nous reste plus qu'à commencer à modifier la fonction `main` que nous avons collée dans `offscreencanvas-cubes.js` depuis l'article sur la réactivité.

Au lieu de rechercher le canevas depuis le DOM, nous le recevrons des données d'événement.

    -function main() {
    -  const canvas = document.querySelector('#c');
    +function main(data) {
    +  const {canvas} = data;
      const renderer = new THREE.WebGLRenderer({antialias: true, canvas});

      ...

En gardant à l'esprit que les workers ne peuvent pas voir le DOM du tout, le premier problème que nous rencontrons est que `resizeRendererToDisplaySize` ne peut pas lire `canvas.clientWidth` et `canvas.clientHeight` car ce sont des valeurs DOM. Voici le code original

    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;
    }

Au lieu de cela, nous devrons envoyer les tailles au worker dès qu'elles changent. Ajoutons donc un état global et conservons la largeur et la hauteur à cet endroit.

    const state = {
      width: 300,  // par défaut du canevas
      height: 150,  // par défaut du canevas
    };

Ensuite, ajoutons un gestionnaire `'size'` pour mettre à jour ces valeurs.

    +function size(data) {
    +  state.width = data.width;
    +  state.height = data.height;
    +}

    const handlers = {
      main,
    +  size,
    };

Maintenant, nous pouvons modifier `resizeRendererToDisplaySize` pour utiliser `state.width` et `state.height`

    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;
    }

et là où nous calculons l'aspect, nous avons besoin de changements similaires

    function render(time) {
      time *= 0.001;

      if (resizeRendererToDisplaySize(renderer)) {
    -    camera.aspect = canvas.clientWidth / canvas.clientHeight;
    +    camera.aspect = state.width / state.height;
        camera.updateProjectionMatrix();
      }

      ...

De retour dans la page principale, nous enverrons un événement `size` chaque fois que la page change de taille.

    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();

Nous l'appelons également une fois pour envoyer la taille initiale.

Et avec ces quelques modifications seulement, en supposant que votre navigateur prenne entièrement en charge `OffscreenCanvas`, cela devrait fonctionner. Avant de l'exécuter, vérifions si le navigateur prend réellement en charge `OffscreenCanvas` et, si ce n'est pas le cas, affichons une erreur. Ajoutons d'abord du HTML pour afficher l'erreur.

    <body>
      <canvas id="c"></canvas>
    +  <div id="noOffscreenCanvas" style="display:none;">
    +    <div>no OffscreenCanvas support</div>
    +  </div>
    </body>

et un peu de CSS pour cela

    #noOffscreenCanvas {
        display: flex;
        width: 100%;
        height: 100%;
        align-items: center;
        justify-content: center;
        background: red;
        color: white;
    }

et ensuite nous pouvons vérifier l'existence de `transferControlToOffscreen` pour voir si le navigateur prend en charge `OffscreenCanvas`

    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]);

      ...

et avec cela, si votre navigateur prend en charge `OffscreenCanvas`, cet exemple devrait fonctionner

cliquez ici pour ouvrir dans une fenêtre séparée

C'est formidable, mais comme tous les navigateurs ne prennent pas en charge `OffscreenCanvas` pour le moment, modifions le code pour qu'il fonctionne à la fois avec `OffscreenCanvas` et, si ce n'est pas le cas, pour qu'il revienne à l'utilisation du canevas dans la page principale comme d'habitude.

> En aparté, si vous avez besoin de OffscreenCanvas pour rendre votre page réactive, alors l'intérêt d'avoir un fallback n'est pas évident. Peut-être que selon si vous exécutez sur la page principale ou dans un worker, vous pourriez ajuster la quantité de travail effectué afin que lorsque vous exécutez dans un worker, vous puissiez faire plus que lorsque vous exécutez dans la page principale. Ce que vous faites dépend entièrement de vous.

La première chose que nous devrions probablement faire est de séparer le code three.js du code spécifique au worker. De cette façon, nous pouvons utiliser le même code sur la page principale et sur le worker. En d'autres termes, nous aurons maintenant 3 fichiers

1. notre fichier html.

   `threejs-offscreencanvas-w-fallback.html`

2. un fichier JavaScript qui contient notre code three.js.

   `shared-cubes.js`

3. notre code de support pour le worker

   `offscreencanvas-worker-cubes.js`

`shared-cubes.js` et `offscreencanvas-worker-cubes.js` sont essentiellement la séparation de notre fichier `offscreencanvas-cubes.js` précédent. Nous copions d'abord tout le contenu de `offscreencanvas-cubes.js` dans `shared-cube.js`. Ensuite, nous renommons `main` en `init` car nous avons déjà une fonction `main` dans notre fichier HTML, et nous devons exporter `init` et `state`

    import * as THREE from 'three';

    -const state = {
    +export const state = {
      width: 300,   // par défaut du canevas
      height: 150,  // par défaut du canevas
    };

    -function main(data) {
    +export function init(data) {
      const {canvas} = data;
      const renderer = new THREE.WebGLRenderer({antialias: true, canvas});

et découpons juste les parties non liées à three.js

    -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);
    -};

Ensuite, nous copions les parties que nous venons de supprimer dans `offscreencanvas-worker-cubes.js` et importons `shared-cubes.js` ainsi qu'appelons `init` au lieu de `main`.

    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);
    };

De même, nous devons inclure `shared-cubes.js` dans la page principale

    <script type="module">
    +import {init, state} from './shared-cubes.js';

Nous pouvons supprimer le HTML et le CSS que nous avons ajoutés précédemment

    <body>
      <canvas id="c"></canvas>
    -  <div id="noOffscreenCanvas" style="display:none;">
    -    <div>no OffscreenCanvas support</div>
    -  </div>
    </body>

et un peu de CSS pour cela

    -#noOffscreenCanvas {
    -    display: flex;
    -    width: 100%;
    -    height: 100%;
    -    align-items: center;
    -    justify-content: center;
    -    background: red;
    -    color: white;
    -}

Ensuite, modifions le code dans la page principale pour appeler une fonction de démarrage ou une autre selon que le navigateur prend en charge `OffscreenCanvas`.

    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);
    +  }
      ...

Nous allons déplacer tout le code que nous avions pour configurer le worker à l'intérieur de `startWorker`

    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');
    }

et envoyer `init` au lieu de `main`

    -  worker.postMessage({type: 'main', canvas: offscreen}, [offscreen]);
    +  worker.postMessage({type: 'init', canvas: offscreen}, [offscreen]);

pour démarrer dans la page principale, nous pouvons faire ceci

    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');
    }

et avec cela, notre exemple s'exécutera soit dans un OffscreenCanvas, soit il reviendra à s'exécuter dans la page principale.

cliquez ici pour ouvrir dans une fenêtre séparée

C'était donc relativement facile. Essayons le picking. Nous allons prendre du code de l'exemple `RayCaster` depuis l'article sur le picking et le faire fonctionner offscreen.

Copions le fichier `shared-cube.js` vers `shared-picking.js` et ajoutons les parties de picking. Nous copions le `PickHelper`

    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();

Nous avons mis à jour `pickPosition` à partir de la souris comme ceci

    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;  // notez que nous inversons Y
    }
    window.addEventListener('mousemove', setPickPosition);

Un worker ne peut pas lire la position de la souris directement, donc tout comme le code de taille, envoyons un message avec la position de la souris. Comme pour le code de taille, nous enverrons la position de la souris et mettrons à jour `pickPosition`

    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);
    };

De retour dans notre page principale, nous devons ajouter du code pour passer la souris au worker ou à la page principale.

    +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 */
    }

Ensuite, nous pouvons copier tout le code de gestion de la souris dans la page principale et apporter juste des modifications mineures pour utiliser `sendMouse`

    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);  // notez que nous inversons Y
    }

    function clearPickPosition() {
      // Contrairement à la souris qui a toujours une position
      // si l'utilisateur arrête de toucher l'écran, nous voulons
      // arrêter le picking. Pour l'instant, nous choisissons juste une valeur
      // peu susceptible de sélectionner quelque chose
    -  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);

et avec cela, le picking devrait fonctionner avec `OffscreenCanvas`.

cliquez ici pour ouvrir dans une fenêtre séparée

Allons un peu plus loin et ajoutons les `OrbitControls`. Cela sera un peu plus complexe. Les `OrbitControls` utilisent le DOM de manière assez extensive pour vérifier la souris, les événements tactiles, et le clavier.

Contrairement à notre code jusqu'à présent, nous ne pouvons pas vraiment utiliser un objet `state` global sans réécrire tout le code des OrbitControls pour qu'il fonctionne avec. Les OrbitControls prennent un `HTMLElement` auquel ils attachent la plupart des événements DOM qu'ils utilisent. Peut-être pourrions-nous passer notre propre objet qui a la même surface d'API qu'un élément DOM. Nous n'avons besoin de prendre en charge que les fonctionnalités dont les OrbitControls ont besoin.

En fouillant dans le code source des OrbitControls, il semble que nous devions gérer les événements suivants.

- contextmenu
- pointerdown
- pointermove
- pointerup
- touchstart
- touchmove
- touchend
- wheel
- keydown

Pour les événements de pointeur, nous avons besoin des propriétés `ctrlKey`, `metaKey`, `shiftKey`, `button`, `pointerType`, `clientX`, `clientY`, `pageX` et `pageY`.

Pour les événements keydown, nous avons besoin des propriétés `ctrlKey`, `metaKey`, `shiftKey` et `keyCode`.

Pour l'événement wheel, nous n'avons besoin que de la propriété `deltaY`.

Et pour les événements tactiles, nous n'avons besoin que de `pageX` et `pageY` de la propriété `touches`.

Alors, créons une paire d'objets proxy. Une partie s'exécutera dans la page principale, capturera tous ces événements et transmettra les valeurs de propriété pertinentes au worker. L'autre partie s'exécutera dans le worker, recevra ces événements et les transmettra en utilisant des événements qui ont la même structure que les événements DOM originaux, de sorte que les OrbitControls ne pourront pas faire la différence.

Voici le code pour la partie worker.

    import {EventDispatcher} from 'three';

    class ElementProxyReceiver extends EventDispatcher {
      constructor() {
        super();
      }
      handleEvent(data) {
        this.dispatchEvent(data);
      }
    }

Tout ce qu'il fait est, s'il reçoit un message, de le dispatcher. Il hérite de `EventDispatcher` qui fournit des méthodes comme `addEventListener` et `removeEventListener`, tout comme un élément DOM, donc si nous le passons aux OrbitControls, cela devrait fonctionner.

`ElementProxyReceiver` gère 1 élément. Dans notre cas, nous n'en avons besoin que d'un, mais il est préférable d'anticiper, alors créons un gestionnaire pour gérer plus d'un.

    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);
      }
    }

Nous pouvons créer une instance de `ProxyManager` et appeler sa méthode `makeProxy` avec un identifiant, ce qui créera un `ElementProxyReceiver` qui répondra aux messages avec cet identifiant.

Connectons-le au gestionnaire de messages de notre worker.

    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);
    };

Dans notre code three.js partagé, nous devons importer les `OrbitControls` et les configurer.

    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();

Notez que nous passons notre proxy aux OrbitControls via `inputElement` au lieu de passer le canevas comme nous le faisons dans d'autres exemples sans OffscreenCanvas.

Ensuite, nous pouvons déplacer tout le code des événements de picking du fichier HTML vers le code three.js partagé également, tout en changeant `canvas` en `inputElement`.

    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;  // notez que nous inversons Y
    }

    function clearPickPosition() {
      // Contrairement à la souris qui a toujours une position
      // si l'utilisateur arrête de toucher l'écran, nous voulons
      // arrêter le picking. Pour l'instant, nous choisissons juste une valeur
      // peu susceptible de sélectionner quelque chose
    -  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);

De retour dans la page principale, nous avons besoin de code pour envoyer des messages pour tous les événements que nous avons énumérés ci-dessus.

    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` prend l'élément dont nous voulons proxifier les événements. Il enregistre ensuite un identifiant auprès du worker en en choisissant un et en l'envoyant via le message `makeProxy` que nous avons configuré précédemment. Le worker créera un `ElementProxyReceiver` et l'enregistrera avec cet identifiant.

Nous avons ensuite un objet de gestionnaires d'événements à enregistrer. De cette façon, nous pouvons passer des gestionnaires uniquement pour les événements que nous voulons transmettre au worker.

Lorsque nous démarrons le worker, nous créons d'abord un proxy et passons nos gestionnaires d'événements.

    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 */
    }

Et voici les gestionnaires d'événements. Tout ce qu'ils font est de copier une liste de propriétés à partir de l'événement qu'ils reçoivent. On leur passe une fonction `sendEvent` à laquelle ils passent les données qu'ils créent. Cette fonction ajoutera l'identifiant correct et l'enverra au worker.

    const mouseEventHandler = makeSendPropertiesHandler([
      'ctrlKey',
      'metaKey',
      'shiftKey',
      'button',
      'pointerType',
      'clientX',
      'clientY',
      'pointerId',
      '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) {
      // preventDefault() corrige les événements mousemove, mouseup et mousedown
      // qui se déclenchent lors d'un simple toucher/relâcher
      // Cela n'arrive qu'avec OffscreenCanvas
      event.preventDefault();
      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,
          clientX: touch.clientX,
          clientY: touch.clientY,
        });
      }
      sendFn(data);
    }

    // Les quatre touches fléchées
    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);
      }
    }

Cela semble proche de fonctionner, mais si nous l'essayons réellement, nous verrons que les `OrbitControls` ont besoin de quelques éléments supplémentaires.

L'une d'elles est qu'ils appellent `element.focus`. Nous n'avons pas besoin que cela se produise dans le worker, alors ajoutons simplement un stub.

    class ElementProxyReceiver extends THREE.EventDispatcher {
      constructor() {
        super();
      }
      handleEvent(data) {
        this.dispatchEvent(data);
      }
    +  focus() {
    +    // sans opération
    +  }
    }

Une autre chose est qu'ils appellent `event.preventDefault` et `event.stopPropagation`. Nous gérons déjà cela dans la page principale, donc ceux-ci peuvent également être un noop (sans opération).

    +function noop() {
    +}

    class ElementProxyReceiver extends THREE.EventDispatcher {
      constructor() {
        super();
      }
      handleEvent(data) {
    +    data.preventDefault = noop;
    +    data.stopPropagation = noop;
        this.dispatchEvent(data);
      }
      focus() {
        // sans opération
      }
    }

Une autre chose est qu'ils regardent `clientWidth` et `clientHeight`. Nous passions la taille auparavant, mais nous pouvons mettre à jour la paire de proxies pour passer cela également.

Dans le worker...

    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() {
        // sans opération
      }
    }

de retour dans la page principale, nous devons envoyer la taille ainsi que les positions gauche et haut également. Notez qu'en l'état, nous ne gérons pas si le canevas se déplace, seulement s'il redimensionne. Si vous vouliez gérer le déplacement, vous devriez appeler `sendSize` chaque fois que quelque chose déplace le canevas.

    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);
      }
    }

et dans notre code three.js partagé, nous n'avons plus besoin de `state`

    -export const state = {
    -  width: 300,   // par défaut du canevas
    -  height: 150,  // par défaut du canevas
    -};

    ...

    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();
      }

      ...

Quelques hacks supplémentaires. Les OrbitControls ajoutent des événements `pointermove` et `pointerup` à l'`ownerDocument` de l'élément pour gérer la capture de la souris (lorsque la souris sort de la fenêtre).

De plus, le code référence le `document` global, mais il n'y a pas de document global dans un worker.

Nous pouvons résoudre tout cela avec 2 hacks rapides. Dans notre code worker, nous allons réutiliser notre proxy pour les deux problèmes.

    function start(data) {
      const proxy = proxyManager.getProxy(data.canvasId);
    +  proxy.ownerDocument = proxy; // HACK!
    +  self.document = {} // HACK!
      init({
        canvas: data.canvas,
        inputElement: proxy,
      });
    }

Cela donnera aux `OrbitControls` quelque chose à inspecter qui correspond à leurs attentes.

Je sais que c'était un peu difficile à suivre. La version courte est la suivante : `ElementProxy` s'exécute sur la page principale et transmet les événements DOM à `ElementProxyReceiver` dans le worker, qui se fait passer pour un `HTMLElement` que nous pouvons utiliser à la fois avec les `OrbitControls` et avec notre propre code.

La dernière chose est notre fallback lorsque nous n'utilisons pas OffscreenCanvas. Tout ce que nous avons à faire est de passer le canevas lui-même comme notre `inputElement`.

    function startMainPage(canvas) {
    -  init({canvas});
    +  init({canvas, inputElement: canvas});
      console.log('using regular canvas');
    }

et maintenant nous devrions avoir les OrbitControls fonctionnant avec OffscreenCanvas

cliquez ici pour ouvrir dans une fenêtre séparée

C'est probablement l'exemple le plus compliqué sur ce site. Il est un peu difficile à suivre car il y a 3 fichiers impliqués pour chaque exemple. Le fichier HTML, le fichier worker, le code three.js partagé.

J'espère que ce n'était pas trop difficile à comprendre et que cela a fourni des exemples utiles pour travailler avec three.js, OffscreenCanvas et les web workers.