NOTE : Les exemples sur cette page nécessitent un appareil compatible VR avec un dispositif de pointage. Sans cela, ils ne fonctionneront pas. Voir cet article pour comprendre pourquoi.
Dans l'article précédent, nous avons examiné un exemple VR très simple où l'utilisateur pouvait choisir des éléments en pointant via le regard. Dans cet article, nous irons un peu plus loin et laisserons l'utilisateur choisir avec un dispositif de pointage.
Three.js rend les choses relativement faciles en fournissant 2 objets contrôleurs en VR et essaie de gérer les deux cas : un seul contrôleur 3DOF et deux contrôleurs 6DOF. Chacun des contrôleurs est un objet Object3D
qui donne l'orientation et la position de ce contrôleur. Ils fournissent également les événements selectstart
, select
et selectend
lorsque l'utilisateur commence à appuyer, appuie, et cesse d'appuyer (termine) sur le bouton "principal" du contrôleur.
En partant du dernier exemple de l'article précédent, changeons le PickHelper
en un ControllerPickHelper
.
Notre nouvelle implémentation émettra un événement select
qui nous donnera l'objet qui a été sélectionné, donc pour l'utiliser, nous aurons juste besoin de faire ceci.
const pickHelper = new ControllerPickHelper(scene); pickHelper.addEventListener('select', (event) => { event.selectedObject.visible = false; const partnerObject = meshToMeshMap.get(event.selectedObject); partnerObject.visible = true; });
Rappelez-vous de notre code précédent : meshToMeshMap
mappe nos boîtes et sphères les unes aux autres, donc si nous en avons une, nous pouvons trouver son partenaire via meshToMeshMap
. Ici, nous cachons simplement l'objet sélectionné et rendons son partenaire visible.
Quant à l'implémentation réelle de ControllerPickHelper
, nous devons d'abord ajouter les objets contrôleurs VR à la scène et y ajouter des lignes 3D que nous pouvons utiliser pour afficher où l'utilisateur pointe. Nous sauvegardons à la fois les contrôleurs et leurs lignes.
class ControllerPickHelper { constructor(scene) { const pointerGeometry = new THREE.BufferGeometry().setFromPoints([ new THREE.Vector3(0, 0, 0), new THREE.Vector3(0, 0, -1), ]); this.controllers = []; for (let i = 0; i < 2; ++i) { const controller = renderer.xr.getController(i); scene.add(controller); const line = new THREE.Line(pointerGeometry); line.scale.z = 5; controller.add(line); this.controllers.push({controller, line}); } } }
Sans rien faire d'autre, cela seul nous donnerait 1 ou 2 lignes dans la scène montrant où se trouvent les dispositifs de pointage de l'utilisateur et dans quelle direction ils pointent.
Cependant, nous avons un problème : nous ne voulons pas que notre RayCaster
sélectionne la ligne elle-même. Une solution facile est de séparer les objets que nous voulions pouvoir sélectionner des objets que nous ne voulons pas en les plaçant sous un autre Object3D
.
const scene = new THREE.Scene(); +// objet pour placer les objets sélectionnables afin de pouvoir les +// séparer facilement des objets non sélectionnables +const pickRoot = new THREE.Object3D(); +scene.add(pickRoot); ... function makeInstance(geometry, color, x) { const material = new THREE.MeshPhongMaterial({color}); const cube = new THREE.Mesh(geometry, material); - scene.add(cube); + pickRoot.add(cube); ...
Ajoutons ensuite du code pour sélectionner à partir des contrôleurs. C'est la première fois que nous sélectionnons avec autre chose que la caméra. Dans notre article sur la sélection, l'utilisateur utilise la souris ou le doigt pour sélectionner, ce qui signifie que la sélection provient de la caméra vers l'écran. Dans l'article précédent, nous sélectionnions en fonction de la direction dans laquelle l'utilisateur regardait, donc cela venait aussi de la caméra. Cette fois, cependant, nous sélectionnons à partir de la position des contrôleurs, donc nous n'utilisons pas la caméra.
class ControllerPickHelper { constructor(scene) { + this.raycaster = new THREE.Raycaster(); + this.objectToColorMap = new Map(); + this.controllerToObjectMap = new Map(); + this.tempMatrix = new THREE.Matrix4(); const pointerGeometry = new THREE.BufferGeometry().setFromPoints([ new THREE.Vector3(0, 0, 0), new THREE.Vector3(0, 0, -1), ]); this.controllers = []; for (let i = 0; i < 2; ++i) { const controller = renderer.xr.getController(i); scene.add(controller); const line = new THREE.Line(pointerGeometry); line.scale.z = 5; controller.add(line); this.controllers.push({controller, line}); } + update(pickablesParent, time) { + this.reset(); + for (const {controller, line} of this.controllers) { + // lancer un rayon depuis le contrôleur + this.tempMatrix.identity().extractRotation(controller.matrixWorld); + this.raycaster.ray.origin.setFromMatrixPosition(controller.matrixWorld); + this.raycaster.ray.direction.set(0, 0, -1).applyMatrix4(this.tempMatrix); + // obtenir la liste des objets intersectés par le rayon + const intersections = this.raycaster.intersectObjects(pickablesParent.children); + if (intersections.length) { + const intersection = intersections[0]; + // faire en sorte que la ligne touche l'objet + line.scale.z = intersection.distance; + // sélectionner le premier objet. C'est le plus proche + const pickedObject = intersection.object; + // sauvegarder quel objet ce contrôleur a sélectionné + this.controllerToObjectMap.set(controller, pickedObject); + // mettre en évidence l'objet si ce n'est pas déjà fait + if (this.objectToColorMap.get(pickedObject) === undefined) { + // sauvegarder sa couleur + this.objectToColorMap.set(pickedObject, pickedObject.material.emissive.getHex()); + // définir sa couleur émissive en rouge/jaune clignotant + pickedObject.material.emissive.setHex((time * 8) % 2 > 1 ? 0xFF2000 : 0xFF0000); + } + } else { + line.scale.z = 5; + } + } + } }
Comme précédemment, nous utilisons un Raycaster
, mais cette fois, nous prenons le rayon depuis le contrôleur. Dans notre précédent PickHelper
, il n'y avait qu'une seule chose pour la sélection, mais ici nous avons jusqu'à 2 contrôleurs, un pour chaque main. Nous sauvegardons l'objet que chaque contrôleur regarde dans controllerToObjectMap
. Nous sauvegardons également la couleur émissive d'origine dans objectToColorMap
et nous faisons en sorte que la ligne soit assez longue pour toucher ce vers quoi elle pointe.
Nous devons ajouter du code pour réinitialiser ces paramètres à chaque image.
class ControllerPickHelper { ... + _reset() { + // restaurer les couleurs + this.objectToColorMap.forEach((color, object) => { + object.material.emissive.setHex(color); + }); + this.objectToColorMap.clear(); + this.controllerToObjectMap.clear(); + } update(pickablesParent, time) { + this._reset(); ... }
Ensuite, nous voulons émettre un événement select
lorsque l'utilisateur clique sur le contrôleur. Pour ce faire, nous pouvons étendre l'EventDispatcher
de three.js, puis nous vérifierons quand nous recevons un événement select
du contrôleur. Si ce contrôleur pointe vers quelque chose, nous émettrons ce vers quoi le contrôleur pointe comme notre propre événement select
.
-class ControllerPickHelper { +class ControllerPickHelper extends THREE.EventDispatcher { constructor(scene) { + super(); this.raycaster = new THREE.Raycaster(); - this.objectToColorMap = new Map(); // object to save color and picked object + this.objectToColorMap = new Map(); // objet pour sauvegarder la couleur et l'objet sélectionné this.controllerToObjectMap = new Map(); this.tempMatrix = new THREE.Matrix4(); const pointerGeometry = new THREE.BufferGeometry().setFromPoints([ new THREE.Vector3(0, 0, 0), new THREE.Vector3(0, 0, -1), ]); this.controllers = []; for (let i = 0; i < 2; ++i) { const controller = renderer.xr.getController(i); + controller.addEventListener('select', (event) => { + const controller = event.target; + const selectedObject = this.controllerToObjectMap.get(controller); + if (selectedObject) { + this.dispatchEvent({type: 'select', controller, selectedObject}); + } + }); scene.add(controller); const line = new THREE.Line(pointerGeometry); line.scale.z = 5; controller.add(line); this.controllers.push({controller, line}); } } }
Il ne reste plus qu'à appeler update
dans notre boucle de rendu.
function render(time) { ... + pickHelper.update(pickablesParent, time); renderer.render(scene, camera); }
et en supposant que vous ayez un appareil VR avec un contrôleur, vous devriez pouvoir utiliser les contrôleurs pour sélectionner des éléments.
Et si nous voulions pouvoir déplacer les objets ?
C'est relativement facile. Déplaçons notre code d'écouteur 'select' du contrôleur dans une fonction afin de pouvoir l'utiliser pour plus d'une chose.
class ControllerPickHelper extends THREE.EventDispatcher { constructor(scene) { super(); ... this.controllers = []; + const selectListener = (event) => { + const controller = event.target; + const selectedObject = this.controllerToObjectMap.get(event.target); + if (selectedObject) { + this.dispatchEvent({type: 'select', controller, selectedObject}); + } + }; for (let i = 0; i < 2; ++i) { const controller = renderer.xr.getController(i); - controller.addEventListener('select', (event) => { - const controller = event.target; - const selectedObject = this.controllerToObjectMap.get(event.target); - if (selectedObject) { - this.dispatchEvent({type: 'select', controller, selectedObject}); - } - }); + controller.addEventListener('select', selectListener); ...
Utilisons-le ensuite pour selectstart
et select
.
class ControllerPickHelper extends THREE.EventDispatcher { constructor(scene) { super(); ... this.controllers = []; const selectListener = (event) => { const controller = event.target; const selectedObject = this.controllerToObjectMap.get(event.target); if (selectedObject) { - this.dispatchEvent({type: 'select', controller, selectedObject}); + this.dispatchEvent({type: event.type, controller, selectedObject}); } }; for (let i = 0; i < 2; ++i) { const controller = renderer.xr.getController(i); controller.addEventListener('select', selectListener); controller.addEventListener('selectstart', selectListener); ...
et transmettons également l'événement selectend
que three.js envoie lorsque l'utilisateur relâche le bouton du contrôleur.
class ControllerPickHelper extends THREE.EventDispatcher { constructor(scene) { super(); ... this.controllers = []; const selectListener = (event) => { const controller = event.target; const selectedObject = this.controllerToObjectMap.get(event.target); if (selectedObject) { this.dispatchEvent({type: event.type, controller, selectedObject}); } }; + const endListener = (event) => { + const controller = event.target; + this.dispatchEvent({type: event.type, controller}); + }; for (let i = 0; i < 2; ++i) { const controller = renderer.xr.getController(i); controller.addEventListener('select', selectListener); controller.addEventListener('selectstart', selectListener); + controller.addEventListener('selectend', endListener); ...
Maintenant, modifions le code de manière à ce que, lorsque nous recevons un événement selectstart
, nous retirions l'objet sélectionné de la scène et en fassions un enfant du contrôleur. Cela signifie qu'il se déplacera avec le contrôleur. Lorsque nous recevrons un événement selectend
, nous le remettrons dans la scène.
const pickHelper = new ControllerPickHelper(scene); -pickHelper.addEventListener('select', (event) => { - event.selectedObject.visible = false; - const partnerObject = meshToMeshMap.get(event.selectedObject); - partnerObject.visible = true; -}); +const controllerToSelection = new Map(); +pickHelper.addEventListener('selectstart', (event) => { + const {controller, selectedObject} = event; + const existingSelection = controllerToSelection.get(controller); + if (!existingSelection) { + controllerToSelection.set(controller, { + object: selectedObject, + parent: selectedObject.parent, + }); + controller.attach(selectedObject); + } +}); + +pickHelper.addEventListener('selectend', (event) => { + const {controller} = event; + const selection = controllerToSelection.get(controller); + if (selection) { + controllerToSelection.delete(controller); + selection.parent.attach(selection.object); + } +});
Lorsqu'un objet est sélectionné, nous sauvegardons cet objet et son parent d'origine. Lorsque l'utilisateur a terminé, nous pouvons remettre l'objet en place.
Nous utilisons Object3D.attach
pour changer le parent des objets sélectionnés. Ces fonctions nous permettent de modifier le parent d'un objet sans modifier son orientation et sa position dans la scène.
Et avec cela, nous devrions pouvoir déplacer les objets avec un contrôleur 6DOF ou au moins changer leur orientation avec un contrôleur 3DOF.
Pour être honnête, je ne suis pas sûr à 100 % que ce ControllerPickHelper
soit la meilleure façon d'organiser le code, mais il est utile pour démontrer les différentes parties nécessaires pour faire fonctionner quelque chose de simple en VR avec three.js.