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
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
notre fichier html.
threejs-offscreencanvas-w-fallback.html
un fichier JavaScript qui contient notre code three.js.
shared-cubes.js
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.
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
.
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.
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
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.