Sélection

La sélection (picking) désigne le processus qui consiste à déterminer sur quel objet un utilisateur a cliqué ou touché. Il existe de nombreuses façons d'implémenter la sélection, chacune ayant ses avantages et ses inconvénients. Passons en revue les 2 méthodes les plus courantes.

La méthode de sélection (picking) probablement la plus courante est le lancé de rayon (raycasting), ce qui signifie lancer un rayon à partir de la souris à travers le frustum (volume de visualisation) de la scène et calculer les objets que ce rayon intersecte. Conceptuellement, c'est très simple.

D'abord, nous prendrions la position de la souris. Nous la convertirions en espace monde en appliquant la projection et l'orientation de la caméra. Nous calculerions un rayon allant du plan proche du frustum de la caméra au plan éloigné. Ensuite, pour chaque triangle de chaque objet dans la scène, nous vérifierions si ce rayon intersecte ce triangle. Si votre scène contient 1000 objets et que chaque objet a 1000 triangles, alors 1 million de triangles devront être vérifiés.

Quelques optimisations incluraient de vérifier d'abord si le rayon intersecte la sphère englobante (bounding sphere) ou la boîte englobante (bounding box) d'un objet, c'est-à-dire la sphère ou la boîte qui contient l'objet entier. Si le rayon n'intersecte pas l'une d'elles, nous n'avons pas besoin de vérifier les triangles de cet objet.

THREE.js fournit une classe RayCaster qui fait exactement cela.

Créons une scène avec 100 objets et essayons de les sélectionner. Nous commencerons avec un exemple tiré de l'article sur les pages responsives

Quelques changements

Nous allons faire de la caméra l'enfant d'un autre objet afin que nous puissions faire tourner cet autre objet et que la caméra se déplace autour de la scène comme un perche à selfie.

*const fov = 60;
const aspect = 2;  // L'aspect par défaut du canvas
const near = 0.1;
*const far = 200;
const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
*camera.position.z = 30;

const scene = new THREE.Scene();
+scene.background = new THREE.Color('white');

+// Place la caméra sur un poteau (la rend enfant d'un objet)
+// afin que nous puissions faire tourner le poteau pour déplacer la caméra autour de la scène
+const cameraPole = new THREE.Object3D();
+scene.add(cameraPole);
+cameraPole.add(camera);

et dans la fonction render, nous ferons tourner le poteau de la caméra.

cameraPole.rotation.y = time * .1;

Mettons aussi la lumière sur la caméra pour qu'elle bouge avec elle.

-scene.add(light);
+camera.add(light);

Générons 100 cubes avec des couleurs aléatoires dans des positions, orientations et échelles aléatoires.

const boxWidth = 1;
const boxHeight = 1;
const boxDepth = 1;
const geometry = new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth);

function rand(min, max) {
  if (max === undefined) {
    max = min;
    min = 0;
  }
  return min + (max - min) * Math.random();
}

function randomColor() {
  return `hsl(${rand(360) | 0}, ${rand(50, 100) | 0}%, 50%)`;
}

const numObjects = 100;
for (let i = 0; i < numObjects; ++i) {
  const material = new THREE.MeshPhongMaterial({
    color: randomColor(),
  });

  const cube = new THREE.Mesh(geometry, material);
  scene.add(cube);

  cube.position.set(rand(-20, 20), rand(-20, 20), rand(-20, 20));
  cube.rotation.set(rand(Math.PI), rand(Math.PI), 0);
  cube.scale.set(rand(3, 6), rand(3, 6), rand(3, 6));
}

Et enfin, effectuons la sélection.

Créons une classe simple pour gérer la sélection

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

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

Vous pouvez voir que nous créons un RayCaster et que nous pouvons ensuite appeler la fonction pick pour lancer un rayon à travers la scène. Si le rayon touche quelque chose, nous changeons la couleur du premier objet qu'il touche.

Bien sûr, nous pourrions appeler cette fonction uniquement lorsque l'utilisateur appuie sur le bouton de la souris (mouse down), ce qui est probablement ce que vous voulez généralement, mais pour cet exemple, nous sélectionnerons à chaque image ce qui se trouve sous la souris. Pour ce faire, nous devons d'abord suivre la position de la souris.

const pickPosition = {x: 0, y: 0};
clearPickPosition();

...

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;  // Note : on inverse Y
}

function clearPickPosition() {
  // Contrairement à la souris qui a toujours une position
  // si l'utilisateur arrête de toucher l'écran, nous voulons
  // arrêter la sélection. Pour l'instant, nous choisissons simplement une valeur
  // peu susceptible de sélectionner quelque chose
  pickPosition.x = -100000;
  pickPosition.y = -100000;
}

window.addEventListener('mousemove', setPickPosition);
window.addEventListener('mouseout', clearPickPosition);
window.addEventListener('mouseleave', clearPickPosition);

Remarquez que nous enregistrons une position de souris normalisée. Indépendamment de la taille du canvas, nous avons besoin d'une valeur qui va de -1 à gauche à +1 à droite. De même, nous avons besoin d'une valeur qui va de -1 en bas à +1 en haut.

Pendant que nous y sommes, prenons également en charge les appareils mobiles.

window.addEventListener('touchstart', (event) => {
  // Empêche le défilement de la fenêtre
  event.preventDefault();
  setPickPosition(event.touches[0]);
}, {passive: false});

window.addEventListener('touchmove', (event) => {
  setPickPosition(event.touches[0]);
});

window.addEventListener('touchend', clearPickPosition);

Et enfin, dans notre fonction render, nous appelons la fonction pick de PickHelper.

+const pickHelper = new PickHelper();

function render(time) {
  time *= 0.001;  // Convertit en secondes ;

  ...

+  pickHelper.pick(pickPosition, scene, camera, time);

  renderer.render(scene, camera);

  ...

et voici le résultat

Cela semble fonctionner parfaitement et c'est probablement le cas pour de nombreuses utilisations, mais il y a plusieurs problèmes.

  1. C'est basé sur le CPU.

    JavaScript parcourt chaque objet et vérifie si le rayon intersecte la boîte ou la sphère englobante de cet objet. Si c'est le cas, JavaScript doit parcourir chaque triangle de cet objet et vérifier si le rayon intersecte le triangle.

    Le bon côté de cela est que JavaScript peut facilement calculer exactement où le rayon a intersecté le triangle et nous fournir ces données. Par exemple, si vous vouliez placer un marqueur là où l'intersection s'est produite.

    Le mauvais côté est que cela représente beaucoup de travail pour le CPU. Si vous avez des objets avec beaucoup de triangles, cela pourrait être lent.

  2. Cela ne gère pas les shaders étranges ou les déplacements.

    Si vous avez un shader qui déforme ou morph le géométrie, JavaScript n'a aucune connaissance de cette déformation et donnera donc la mauvaise réponse. Par exemple, à ma connaissance, vous ne pouvez pas utiliser cette méthode avec des objets skinnés (avec animation par squelette).

  3. Cela ne gère pas les trous transparents.

À titre d'exemple, appliquons cette texture aux cubes.

Nous allons juste apporter ces modifications

+const loader = new THREE.TextureLoader();
+const texture = loader.load('resources/images/frame.png');

const numObjects = 100;
for (let i = 0; i < numObjects; ++i) {
  const material = new THREE.MeshPhongMaterial({
    color: randomColor(),
    +map: texture,
    +transparent: true,
    +side: THREE.DoubleSide,
    +alphaTest: 0.1,
  });

  const cube = new THREE.Mesh(geometry, material);
  scene.add(cube);

  ...

Et en exécutant cela, vous devriez rapidement voir le problème

Essayez de sélectionner quelque chose à travers une boîte et vous ne le pouvez pas

C'est parce que JavaScript ne peut pas facilement examiner les textures et les matériaux et déterminer si une partie de votre objet est réellement transparente ou non.

Une solution à tous ces problèmes est d'utiliser la sélection basée sur le GPU. Malheureusement, bien que conceptuellement simple, elle est plus compliquée à utiliser que la méthode de lancé de rayon ci-dessus.

Pour faire de la sélection par GPU, nous rendons chaque objet dans une couleur unique hors écran. Nous consultons ensuite la couleur du pixel correspondant à la position de la souris. La couleur nous indique quel objet a été sélectionné.

Cela peut résoudre les problèmes 2 et 3 ci-dessus. Quant au problème 1, la vitesse, cela dépend vraiment. Chaque objet doit être dessiné deux fois. Une fois pour l'affichage normal et encore pour la sélection. Il est possible avec des solutions plus sophistiquées que les deux puissent être faites en même temps, mais nous n'allons pas essayer cela.

Une chose que nous pouvons faire, cependant, puisque nous ne lirons qu'un seul pixel, est de configurer la caméra de manière à ce que seul ce pixel soit dessiné. Nous pouvons le faire en utilisant PerspectiveCamera.setViewOffset qui nous permet de dire à THREE.js de calculer une caméra qui rend juste une partie plus petite d'un rectangle plus grand. Cela devrait faire gagner du temps.

Pour effectuer ce type de sélection dans THREE.js à l'heure actuelle, il faut créer 2 scènes. L'une que nous remplirons avec nos maillages normaux. L'autre que nous remplirons avec des maillages qui utilisent notre matériau de sélection.

Donc, d'abord, créez une deuxième scène et assurez-vous qu'elle se vide en noir.

const scene = new THREE.Scene();
scene.background = new THREE.Color('white');
const pickingScene = new THREE.Scene();
pickingScene.background = new THREE.Color(0);

Ensuite, pour chaque cube que nous plaçons dans la scène principale, nous créons un "cube de sélection" correspondant à la même position que le cube original, le plaçons dans la pickingScene, et définissons son matériau de manière à dessiner l'identifiant de l'objet comme sa couleur. Nous conservons également une carte des identifiants vers les objets afin que lorsque nous recherchons un identifiant plus tard, nous puissions le mapper à l'objet correspondant.

const idToObject = {};
+const numObjects = 100;
for (let i = 0; i < numObjects; ++i) {
+  const id = i + 1;
  const material = new THREE.MeshPhongMaterial({
    color: randomColor(),
    map: texture,
    transparent: true,
    side: THREE.DoubleSide,
    alphaTest: 0.1,
  });

  const cube = new THREE.Mesh(geometry, material);
  scene.add(cube);
+  idToObject[id] = cube;

  cube.position.set(rand(-20, 20), rand(-20, 20), rand(-20, 20));
  cube.rotation.set(rand(Math.PI), rand(Math.PI), 0);
  cube.scale.set(rand(3, 6), rand(3, 6), rand(3, 6));

+  const pickingMaterial = new THREE.MeshPhongMaterial({
+    emissive: new THREE.Color().setHex(id, THREE.NoColorSpace),
+    color: new THREE.Color(0, 0, 0),
+    specular: new THREE.Color(0, 0, 0),
+    map: texture,
+    transparent: true,
+    side: THREE.DoubleSide,
+    alphaTest: 0.5,
+    blending: THREE.NoBlending,
+  });
+  const pickingCube = new THREE.Mesh(geometry, pickingMaterial);
+  pickingScene.add(pickingCube);
+  pickingCube.position.copy(cube.position);
+  pickingCube.rotation.copy(cube.rotation);
+  pickingCube.scale.copy(cube.scale);
}

Notez que nous faisons un usage "abusif" du MeshPhongMaterial ici. En définissant son emissive sur notre identifiant et les attributs color et specular à 0, cela finira par rendre l'identifiant uniquement là où l'alpha de la texture est supérieur à alphaTest. Nous devons également définir blending à NoBlending afin que l'identifiant ne soit pas multiplié par l'alpha.

Notez que l'abus du MeshPhongMaterial pourrait ne pas être la meilleure solution car il calculera toujours toutes nos lumières lors du dessin de la scène de sélection, même si nous n'avons pas besoin de ces calculs. Une solution plus optimisée créerait un shader personnalisé qui écrit simplement l'identifiant là où l'alpha de la texture est supérieur à alphaTest.

Comme nous sélectionnons à partir de pixels au lieu de lancer des rayons, nous pouvons modifier le code qui définit la position de sélection pour utiliser simplement des pixels.

function setPickPosition(event) {
  const pos = getCanvasRelativePosition(event);
-  pickPosition.x = (pos.x / canvas.clientWidth ) *  2 - 1;
-  pickPosition.y = (pos.y / canvas.clientHeight) * -2 + 1;  // Note : on inverse Y
+  pickPosition.x = pos.x;
+  pickPosition.y = pos.y;
}

Ensuite, changeons la classe PickHelper en GPUPickHelper. Elle utilisera un WebGLRenderTarget comme nous l'avons vu dans l'article sur les render targets. Notre render target ici ne fait qu'un seul pixel, 1x1.

-class PickHelper {
+class GPUPickHelper {
  constructor() {
-    this.raycaster = new THREE.Raycaster();
+    // Crée un render target de 1x1 pixel
+    this.pickingTexture = new THREE.WebGLRenderTarget(1, 1);
+    this.pixelBuffer = new Uint8Array(4);
    this.pickedObject = null;
    this.pickedObjectSavedColor = 0;
  }
  pick(cssPosition, scene, camera, time) {
+    const {pickingTexture, pixelBuffer} = this;

    // Rétablit la couleur s'il y a un objet sélectionné
    if (this.pickedObject) {
      this.pickedObject.material.emissive.setHex(this.pickedObjectSavedColor);
      this.pickedObject = undefined;
    }

+    // Définit le décalage de vue pour représenter juste un seul pixel sous la souris
+    const pixelRatio = renderer.getPixelRatio();
+    camera.setViewOffset(
+        renderer.getContext().drawingBufferWidth,   // Largeur totale
+        renderer.getContext().drawingBufferHeight,  // Hauteur totale
+        cssPosition.x * pixelRatio | 0,             // rect x
+        cssPosition.y * pixelRatio | 0,             // rect y
+        1,                                          // rect largeur
+        1,                                          // rect hauteur
+    );
+    // Rend la scène
+    renderer.setRenderTarget(pickingTexture)
+    renderer.render(scene, camera);
+    renderer.setRenderTarget(null);
+
+    // Efface le décalage de vue pour que le rendu redevienne normal
+    camera.clearViewOffset();
+    // Lit le pixel
+    renderer.readRenderTargetPixels(
+        pickingTexture,
+        0,   // x
+        0,   // y
+        1,   // largeur
+        1,   // hauteur
+        pixelBuffer);
+
+    const id =
+        (pixelBuffer[0] << 16) |
+        (pixelBuffer[1] <<  8) |
+        (pixelBuffer[2]      );

-    // Lance un rayon à travers le frustum
-    this.raycaster.setFromCamera(normalizedPosition, camera);
-    // Obtient la liste des objets intersectés par le rayon
-    const intersectedObjects = this.raycaster.intersectObjects(scene.children);
-    if (intersectedObjects.length) {
-      // Sélectionne le premier objet. C'est le plus proche
-      this.pickedObject = intersectedObjects[0].object;

+    const intersectedObject = idToObject[id];
+    if (intersectedObject) {
+      // Sélectionne le premier objet. C'est le plus proche
+      this.pickedObject = intersectedObject;
      // Sauvegarde sa couleur
      this.pickedObjectSavedColor = this.pickedObject.material.emissive.getHex();
      // Définit sa couleur émissive sur un rouge/jaune clignotant
      this.pickedObject.material.emissive.setHex((time * 8) % 2 > 1 ? 0xFFFF00 : 0xFF0000);
    }
  }
}

Et ensuite, nous devons juste l'utiliser

-const pickHelper = new PickHelper();
+const pickHelper = new GPUPickHelper();

et lui passer la pickScene au lieu de la scene.

-  pickHelper.pick(pickPosition, scene, camera, time);
+  pickHelper.pick(pickPosition, pickScene, camera, time);

Et maintenant, cela devrait vous permettre de sélectionner à travers les parties transparentes.

J'espère que cela vous donne une idée de la manière d'implémenter la sélection. Dans un futur article, nous pourrons peut-être aborder la manière de manipuler des objets avec la souris.