VR - Sélection par le regard

NOTE : Les exemples de cette page nécessitent un appareil compatible VR. Sans cela, ils ne fonctionneront pas. Voir l'article précédent pour comprendre pourquoi.

Dans l'article précédent, nous avons abordé un exemple VR très simple utilisant three.js et nous avons discuté des différents types de systèmes VR.

Le plus simple et probablement le plus courant est le style VR Google Cardboard qui consiste essentiellement en un téléphone placé dans un masque facial coûtant entre 5 et 50 dollars. Ce type de VR n'a pas de contrôleur, les gens doivent donc trouver des solutions créatives pour permettre l'entrée utilisateur.

La solution la plus courante est la "sélection par le regard" où si l'utilisateur pointe sa tête vers quelque chose pendant un moment, cela est sélectionné.

Implémentons la "sélection par le regard" ! Nous allons commencer par un exemple de l'article précédent et pour ce faire, nous ajouterons le PickHelper que nous avons créé dans l'article sur le picking. Le voici.

class PickHelper {
  constructor() {
    this.raycaster = new THREE.Raycaster();
    this.pickedObject = null;
    this.pickedObjectSavedColor = 0;
  }
  pick(normalizedPosition, scene, camera, time) {
    // restaurer la couleur s'il y a un objet sélectionné
    if (this.pickedObject) {
      this.pickedObject.material.emissive.setHex(this.pickedObjectSavedColor);
      this.pickedObject = undefined;
    }

    // lancer un rayon à travers le frustum
    this.raycaster.setFromCamera(normalizedPosition, camera);
    // obtenir la liste des objets intersectés par le rayon
    const intersectedObjects = this.raycaster.intersectObjects(scene.children);
    if (intersectedObjects.length) {
      // sélectionner le premier objet. C'est le plus proche
      this.pickedObject = intersectedObjects[0].object;
      // sauvegarder sa couleur
      this.pickedObjectSavedColor = this.pickedObject.material.emissive.getHex();
      // définir sa couleur d'émission sur rouge/jaune clignotant
      this.pickedObject.material.emissive.setHex((time * 8) % 2 > 1 ? 0xFFFF00 : 0xFF0000);
    }
  }
}

Pour une explication de ce code, voir l'article sur le picking.

Pour l'utiliser, il suffit de créer une instance et de l'appeler dans notre boucle de rendu.

+const pickHelper = new PickHelper();

...
function render(time) {
  time *= 0.001;

  ...

+  // 0, 0 est le centre de la vue en coordonnées normalisées.
+  pickHelper.pick({x: 0, y: 0}, scene, camera, time);

Dans l'exemple de picking original, nous avons converti les coordonnées de la souris des pixels CSS en coordonnées normalisées qui vont de -1 à +1 sur le canevas.

Dans ce cas, cependant, nous sélectionnerons toujours l'endroit où la caméra est dirigée, c'est-à-dire le centre de l'écran, nous passons donc 0 pour x et y, ce qui correspond au centre en coordonnées normalisées.

Et avec cela, les objets clignoteront lorsque nous les regarderons.

Généralement, nous ne voulons pas que la sélection soit immédiate. Au lieu de cela, nous demandons à l'utilisateur de maintenir la caméra sur l'objet qu'il souhaite sélectionner pendant quelques instants afin de lui donner une chance de ne pas sélectionner quelque chose par accident.

Pour ce faire, nous avons besoin d'une sorte de compteur ou de jauge ou d'un moyen quelconque pour indiquer que l'utilisateur doit continuer à regarder et pendant combien de temps.

Une façon simple de procéder est de créer une texture à 2 couleurs et d'utiliser un décalage de texture pour faire glisser la texture sur un modèle.

Faisons cela séparément pour voir comment cela fonctionne avant de l'ajouter à l'exemple VR.

Tout d'abord, nous créons une OrthographicCamera.

const left = -2;    // Utiliser les valeurs pour gauche
const right = 2;    // droite, haut et bas
const top = 1;      // qui correspondent à la taille
const bottom = -1;  // par défaut du canevas.
const near = -1;
const far = 1;
const camera = new THREE.OrthographicCamera(left, right, top, bottom, near, far);

Et bien sûr, la mettre à jour si la taille du canevas change.

function render(time) {
  time *= 0.001;

  if (resizeRendererToDisplaySize(renderer)) {
    const canvas = renderer.domElement;
    const aspect = canvas.clientWidth / canvas.clientHeight;
+    camera.left = -aspect;
+    camera.right = aspect;
    camera.updateProjectionMatrix();
  }
  ...

Nous avons maintenant une caméra qui montre 2 unités au-dessus et en dessous du centre et des unités d'aspect à gauche et à droite.

Ensuite, créons une texture à 2 couleurs. Nous utiliserons une DataTexture que nous avons utilisée à quelques autres endroits.

function makeDataTexture(data, width, height) {
  const texture = new THREE.DataTexture(data, width, height, THREE.RGBAFormat);
  texture.minFilter = THREE.NearestFilter;
  texture.magFilter = THREE.NearestFilter;
  texture.needsUpdate = true;
  return texture;
}

const cursorColors = new Uint8Array([
  64, 64, 64, 64,       // gris foncé
  255, 255, 255, 255,   // blanc
]);
const cursorTexture = makeDataTexture(cursorColors, 2, 1);

Nous utiliserons ensuite cette texture sur une TorusGeometry.

const ringRadius = 0.4;
const tubeRadius = 0.1;
const tubeSegments = 4;
const ringSegments = 64;
const cursorGeometry = new THREE.TorusGeometry(
    ringRadius, tubeRadius, tubeSegments, ringSegments);

const cursorMaterial = new THREE.MeshBasicMaterial({
  color: 'white',
  map: cursorTexture,
  transparent: true,
  blending: THREE.CustomBlending,
  blendSrc: THREE.OneMinusDstColorFactor,
  blendDst: THREE.OneMinusSrcColorFactor,
});
const cursor = new THREE.Mesh(cursorGeometry, cursorMaterial);
scene.add(cursor);

et ensuite dans render, ajustons le décalage de la texture.

function render(time) {
  time *= 0.001;

  if (resizeRendererToDisplaySize(renderer)) {
    const canvas = renderer.domElement;
    const aspect = canvas.clientWidth / canvas.clientHeight;
    camera.left = -aspect;
    camera.right = aspect;
    camera.updateProjectionMatrix();
  }

+  const fromStart = 0;
+  const fromEnd = 2;
+  const toStart = -0.5;
+  const toEnd = 0.5;
+  cursorTexture.offset.x = THREE.MathUtils.mapLinear(
+      time % 2,
+      fromStart, fromEnd,
+      toStart, toEnd);

  renderer.render(scene, camera);
}

THREE.MathUtils.mapLinear prend une valeur qui se situe entre fromStart et fromEnd et la mappe à une valeur entre toStart et toEnd. Dans le cas ci-dessus, nous prenons time % 2, ce qui signifie une valeur qui va de 0 à 2 et la mappons à une valeur qui va de -0.5 à 0.5.

Les textures sont mappées à la géométrie en utilisant des coordonnées de texture normalisées qui vont de 0 à 1. Cela signifie que notre image de 2x1 pixels, définie sur le mode de répétition par défaut de THREE.ClampToEdge, si nous ajustons les coordonnées de texture de -0.5, alors toute la maille sera de la première couleur et si nous ajustons les coordonnées de texture de +0.5, toute la maille sera de la deuxième couleur. Entre les deux, avec le filtrage défini sur THREE.NearestFilter, nous pourrons déplacer la transition entre les 2 couleurs à travers la géométrie.

Ajoutons une texture d'arrière-plan tant que nous y sommes, comme nous l'avons vu dans l'article sur les arrière-plans. Nous utiliserons simplement un ensemble de couleurs 2x2, mais définirons les paramètres de répétition de la texture pour nous donner une grille 8x8. Cela donnera à notre curseur quelque chose sur lequel être rendu afin que nous puissions le vérifier par rapport à différentes couleurs.

+const backgroundColors = new Uint8Array([
+    0,   0,   0, 255,  // noir
+   90,  38,  38, 255,  // rouge foncé
+  100, 175, 103, 255,  // vert moyen
+  255, 239, 151, 255,  // jaune clair
+]);
+const backgroundTexture = makeDataTexture(backgroundColors, 2, 2);
+backgroundTexture.wrapS = THREE.RepeatWrapping;
+backgroundTexture.wrapT = THREE.RepeatWrapping;
+backgroundTexture.repeat.set(4, 4);

const scene = new THREE.Scene();
+scene.background = backgroundTexture;

Maintenant, si nous exécutons cela, vous verrez que nous obtenons une jauge en forme de cercle et que nous pouvons définir où se trouve la jauge.

Quelques points à noter et à essayer.

  • Nous avons défini les propriétés blending, blendSrc et blendDst du cursorMaterial comme suit :

      blending: THREE.CustomBlending,
      blendSrc: THREE.OneMinusDstColorFactor,
      blendDst: THREE.OneMinusSrcColorFactor,
    

    Cela donne un effet de type inverse. Commentez ces 3 lignes et vous verrez la différence. Je suppose simplement que l'effet inverse est le meilleur ici, car de cette façon, nous pouvons, espérons-le, voir le curseur quelles que soient les couleurs sur lesquelles il se trouve.

  • Nous utilisons une TorusGeometry et non une RingGeometry.

    Pour une raison quelconque, la RingGeometry utilise un schéma de mappage UV plat. De ce fait, si nous utilisons une RingGeometry, la texture glisse horizontalement sur l'anneau au lieu de l'entourer comme c'est le cas ci-dessus.

    Essayez, changez la TorusGeometry en une RingGeometry (elle est simplement commentée dans l'exemple ci-dessus) et vous verrez ce que je veux dire.

    La chose la plus correcte à faire (selon une certaine définition de correct) serait soit d'utiliser la RingGeometry mais de corriger les coordonnées de texture pour qu'elles fassent le tour de l'anneau. Ou bien, générer notre propre géométrie d'anneau. Mais, le tore fonctionne très bien. Placé directement devant la caméra avec un MeshBasicMaterial, il ressemblera exactement à un anneau et les coordonnées de texture font le tour de l'anneau, donc cela fonctionne pour nos besoins.

Intégrons-le avec notre code VR ci-dessus.

class PickHelper {
-  constructor() {
+  constructor(camera) {
    this.raycaster = new THREE.Raycaster();
    this.pickedObject = null;
-    this.pickedObjectSavedColor = 0;

+    const cursorColors = new Uint8Array([
+      64, 64, 64, 64,       // gris foncé
+      255, 255, 255, 255,   // blanc
+    ]);
+    this.cursorTexture = makeDataTexture(cursorColors, 2, 1);
+
+    const ringRadius = 0.4;
+    const tubeRadius = 0.1;
+    const tubeSegments = 4;
+    const ringSegments = 64;
+    const cursorGeometry = new THREE.TorusGeometry(
+        ringRadius, tubeRadius, tubeSegments, ringSegments);
+
+    const cursorMaterial = new THREE.MeshBasicMaterial({
+      color: 'white',
+      map: this.cursorTexture,
+      transparent: true,
+      blending: THREE.CustomBlending,
+      blendSrc: THREE.OneMinusDstColorFactor,
+      blendDst: THREE.OneMinusSrcColorFactor,
+    });
+    const cursor = new THREE.Mesh(cursorGeometry, cursorMaterial);
+    // ajouter le curseur comme enfant de la caméra
+    camera.add(cursor);
+    // et le déplacer devant la caméra
+    cursor.position.z = -1;
+    const scale = 0.05;
+    cursor.scale.set(scale, scale, scale);
+    this.cursor = cursor;
+
+    this.selectTimer = 0;
+    this.selectDuration = 2;
+    this.lastTime = 0;
  }
  pick(normalizedPosition, scene, camera, time) {
+    const elapsedTime = time - this.lastTime;
+    this.lastTime = time;

-    // restore the color if there is a picked object
-    if (this.pickedObject) {
-      this.pickedObject.material.emissive.setHex(this.pickedObjectSavedColor);
-      this.pickedObject = undefined;
-    }

+    const lastPickedObject = this.pickedObject;
+    this.pickedObject = undefined;

    // lancer un rayon à travers le frustum
    this.raycaster.setFromCamera(normalizedPosition, camera);
    // obtenir la liste des objets intersectés par le rayon
    const intersectedObjects = this.raycaster.intersectObjects(scene.children);
    if (intersectedObjects.length) {
      // sélectionner le premier objet. C'est le plus proche
      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);
    }

+    // afficher le curseur uniquement s'il touche quelque chose
+    this.cursor.visible = this.pickedObject ? true : false;
+
+    let selected = false;
+
+    // si nous regardons le même objet qu'avant
+    // incrémenter le minuteur de sélection
+    if (this.pickedObject && lastPickedObject === this.pickedObject) {
+      this.selectTimer += elapsedTime;
+      if (this.selectTimer >= this.selectDuration) {
+        this.selectTimer = 0;
+        selected = true;
+      }
+    } else {
+      this.selectTimer = 0;
+    }
+
+    // définir le matériau du curseur pour afficher l'état du minuteur
+    const fromStart = 0;
+    const fromEnd = this.selectDuration;
+    const toStart = -0.5;
+    const toEnd = 0.5;
+    this.cursorTexture.offset.x = THREE.MathUtils.mapLinear(
+        this.selectTimer,
+        fromStart, fromEnd,
+        toStart, toEnd);
+
+    return selected ? this.pickedObject : undefined;
  }
}

Vous pouvez voir dans le code ci-dessus que nous avons ajouté tout le code pour créer la géométrie, la texture et le matériau du curseur, et nous l'avons ajouté comme enfant de la caméra afin qu'il soit toujours devant la caméra. Notez que nous devons ajouter la caméra à la scène, sinon le curseur ne sera pas rendu.

+scene.add(camera);

Nous vérifions ensuite si l'objet que nous sélectionnons cette fois est le même que la dernière fois. Si c'est le cas, nous ajoutons le temps écoulé à un minuteur et si le minuteur atteint sa limite, nous retournons l'élément sélectionné.

Maintenant, utilisons cela pour sélectionner les cubes. Comme simple exemple, nous allons ajouter également 3 sphères. Lorsqu'un cube est sélectionné, nous cachons le cube et révélons la sphère correspondante.

Donc, d'abord, nous allons créer une géométrie de sphère.

const boxWidth = 1;
const boxHeight = 1;
const boxDepth = 1;
-const geometry = new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth);
+const boxGeometry = new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth);
+
+const sphereRadius = 0.5;
+const sphereGeometry = new THREE.SphereGeometry(sphereRadius);

Ensuite, créons 3 paires de maillages (meshes) boîte et sphère. Nous utiliserons une Map afin de pouvoir associer chaque Mesh à son partenaire.

-const cubes = [
-  makeInstance(geometry, 0x44aa88,  0),
-  makeInstance(geometry, 0x8844aa, -2),
-  makeInstance(geometry, 0xaa8844,  2),
-];
+const meshToMeshMap = new Map();
+[
+  { x:  0, boxColor: 0x44aa88, sphereColor: 0xFF4444, },
+  { x:  2, boxColor: 0x8844aa, sphereColor: 0x44FF44, },
+  { x: -2, boxColor: 0xaa8844, sphereColor: 0x4444FF, },
+].forEach((info) => {
+  const {x, boxColor, sphereColor} = info;
+  const sphere = makeInstance(sphereGeometry, sphereColor, x);
+  const box = makeInstance(boxGeometry, boxColor, x);
+  // cacher la sphère
+  sphere.visible = false;
+  // mapper la sphère à la boîte
+  meshToMeshMap.set(box, sphere);
+  // mapper la boîte à la sphère
+  meshToMeshMap.set(sphere, box);
+});

Dans render, où nous faisons tourner les cubes, nous devons itérer sur meshToMeshMap au lieu de cubes.

-cubes.forEach((cube, ndx) => {
+let ndx = 0;
+for (const mesh of meshToMeshMap.keys()) {
  const speed = 1 + ndx * .1;
  const rot = time * speed;
-  cube.rotation.x = rot;
-  cube.rotation.y = rot;
-});
+  mesh.rotation.x = rot;
+  mesh.rotation.y = rot;
+  ++ndx;
+}

Et maintenant, nous pouvons utiliser notre nouvelle implémentation de PickHelper pour sélectionner l'un des objets. Lorsqu'il est sélectionné, nous cachons cet objet et révélons son partenaire.

// 0, 0 est le centre de la vue en coordonnées normalisées.
-pickHelper.pick({x: 0, y: 0}, scene, camera, time);
+const selectedObject = pickHelper.pick({x: 0, y: 0}, scene, camera, time);
+if (selectedObject) {
+  selectedObject.visible = false;
+  const partnerObject = meshToMeshMap.get(selectedObject);
+  partnerObject.visible = true;
+}

Et avec cela, nous devrions avoir une implémentation assez correcte de la sélection par le regard.

J'espère que cet exemple vous a donné quelques idées sur la façon d'implémenter une interface utilisateur de type "sélection par le regard" au niveau de Google Cardboard. Faire glisser des textures en utilisant les décalages des coordonnées de texture est également une technique couramment utile.

Ensuite, permettons à l'utilisateur disposant d'un contrôleur VR de pointer et de déplacer des objets.